openvibers/openvibe.sites Commits
The placeholders run this repository's pinned OpenVibe Frame (roadmap WS-P task 4, D42: the Sites placeholders; openvibe-shared 1.22.0 -> 1.23.0). build.js writes one copy of every openvibe-shared browser file into dist/_shared/, and every generated vhost aliases /shared/ to it (an hour's cache, CORS-open, the security headers). The pages load theme-loader, ov-mark, navbar and footer as /shared/<file>?v=<content hash> instead of https://openvibe.network/shared/, so a placeholder runs what Sites pins and keeps its frame while Network is down. test/build.test.js checks, per site, that no shared file comes from openvibe.network, that each /shared URL carries its file's hash, that dist/_shared holds exactly the pinned file, and that the vhost aliases /shared/.
Changes
@@@ -17,6 +17,10 @@ const fs = require('fs');const path = require('path');// The network's legal documents, from the pinned OpenVibe.Shared release (package.json).const legal = require('openvibe-shared/legal');++// D42 (roadmap WS-P task 4): the pages run this repository's pinned copy of the shared browser files, served by++// every Sites vhost at /shared/ from one copy in dist/_shared/, content-addressed (?v=), never openvibe.network's.++const sharedFiles = require('openvibe-shared/files');++const SHARED = (name) => require('openvibe-shared/serve').url(name);// Which clauses apply to each domain once it opens (mirrors OpenVibe.Network/server/frame/sites.js).const LEGAL_PROFILE = { chat: 'ugc', codes: 'ugc', blog: 'info', wiki: 'ugc', news: 'info', reviews: 'ugc', tips: 'streaming', vip: 'account', trade: 'ugc', host: 'hosting', deals: 'info', coupons: 'info', stream: 'streaming' };// Sites whose own server has no page routes: their legal pages are built here and served by nginx.@@@ -142,7 +146,7 @@ ${require('openvibe-shared/app-icon').CRITICAL}<meta name="twitter:image" content="${ogImage}"><script type="application/ld+json">${JSON.stringify(ld)}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="${NET.networkUrl}/shared/theme-loader.js" defer></script>++<script src="${SHARED('theme-loader.js')}" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -263,9 +267,9 @@ ${site.statusApi ? liveStatus(site) : ''}` : ` <section id="what"></div></main>${require('openvibe-shared/frame').footer({ service: site.tld, variant: 'full' })}--<script src="${NET.networkUrl}/shared/ov-mark.js" async></script>--<script src="${NET.networkUrl}/shared/navbar.js"></script>--<script src="${NET.networkUrl}/shared/footer.js"></script>++<script src="${SHARED('ov-mark.js')}" async></script>++<script src="${SHARED('navbar.js')}"></script>++<script src="${SHARED('footer.js')}"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: '${esc(site.tld)}', apiBase: '${NET.networkUrl}', links: [{ label: '${notice ? 'Where to go' : 'What it will be'}', href: '#what' }, { label: 'The network', href: '${NET.networkUrl}/#network', external: false }], history: { type: 'page', title: '${esc(site.name)}' } }); } catch (e) {} }@@@ -401,6 +405,15 @@ server {add_header Access-Control-Allow-Origin "*";${sec}}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ ${sec}++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -517,6 +530,8 @@ function build() {out[`../deploy/nginx/${site.domain}.conf`] = vhost(site);}for (const g of LEGAL_ONLY) for (const kind of ['terms', 'privacy', 'dmca']) out[`${g.domain}/${kind}.html`] = legal.page(kind, { id: g.id, service: g.id, host: g.domain, name: g.name, profile: g.profile });++ // One copy of every browser file of the pinned openvibe-shared (the pages' scripts and what they load beside them).++ for (const name of sharedFiles.BROWSER) out[`_shared/${name}`] = fs.readFileSync(sharedFiles.path(name), 'utf8');return out;}@@@ -71,6 +71,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -71,6 +71,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -71,6 +71,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -71,6 +71,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -81,6 +81,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -81,6 +81,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -81,6 +81,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -81,6 +81,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -81,6 +81,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -81,6 +81,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -81,6 +81,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -81,6 +81,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -81,6 +81,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -71,6 +71,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -71,6 +71,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;@@@ -71,6 +71,18 @@ server {add_header Referrer-Policy strict-origin-when-cross-origin always;add_header Strict-Transport-Security "max-age=31536000" always;}++ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites++ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get++ # the same hour.++ location ^~ /shared/ {++ alias /opt/openvibe.sites/dist/_shared/;++ add_header Cache-Control "public, max-age=3600";++ add_header Access-Control-Allow-Origin "*";++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000" always;++ }# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.error_page 404 /404.html;nnew file mode 100644@@@ -0,0 +1,684 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Account Switcher++// Google-style multi-account management with anonymous mode.++// Persists sessions in localStorage, coordinates with server++// for token swapping. Integrates with OpenVibeNavbar dropdown.++// Usage: OpenVibeAccountSwitcher.init({ apiBase, onSwitch })++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ const STORAGE_KEY = 'openvibe_accounts';++ const TOKEN_KEY = 'ov_token';++ const ACTIVE_KEY = 'openvibe_active_account';++ const ANON_KEY = 'openvibe_anon_token';++ const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>++ const MAX_ACCOUNTS = 10;++++ let _config = { apiBase: 'https://openvibe.network', onSwitch: null };++ let _panelEl = null;++++ function isAnonId(id) {++ return String(id) === 'anon' || String(id).startsWith('anon_');++ }++++ /** Store of session tokens per anon identity */++ function getAnonTokens() {++ try { return JSON.parse(localStorage.getItem(ANON_TOKENS_KEY) || '{}'); } catch { return {}; }++ }++ function saveAnonTokens(tokens) {++ localStorage.setItem(ANON_TOKENS_KEY, JSON.stringify(tokens));++ }++ function setAnonToken(anonId, token) {++ const tokens = getAnonTokens();++ tokens[anonId] = token;++ saveAnonTokens(tokens);++ }++ function getAnonToken(anonId) {++ return getAnonTokens()[anonId] || null;++ }++ function removeAnonToken(anonId) {++ const tokens = getAnonTokens();++ delete tokens[anonId];++ saveAnonTokens(tokens);++ }++++ // Cookie policy (CONTRACTS.md): ov_token is host-only everywhere EXCEPT on++ // openvibe.tools + its subdomains, where a Domain=.openvibe.tools cookie is++ // shared across the tool satellites.++ function onToolsDomain() {++ const host = (typeof location !== 'undefined' && location.hostname) || '';++ return host === 'openvibe.tools' || host.endsWith('.openvibe.tools');++ }++++ function clearAuthCookie() {++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';++ if (onToolsDomain()) {++ document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools;SameSite=Lax';++ }++ }++++ function setAuthCookie(token) {++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';++ if (onToolsDomain()) {++ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};domain=.openvibe.tools;SameSite=Lax;Secure`;++ } else {++ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};SameSite=Lax${secure}`;++ }++ }++++ function normalizeAccount(user, token) {++ const isAnon = !!user?.is_anon || isAnonId(user?.id);++ const anonNum = user.anon_number || null;++ // Each anon gets a unique ID based on its anon_number++ const id = isAnon ? (anonNum ? `anon_${anonNum}` : 'anon') : user.id;++ return {++ id,++ username: user.username,++ display_name: user.display_name || user.username,++ avatar_url: user.avatar_url || null,++ email: user.email || null,++ is_anon: isAnon,++ anon_number: anonNum,++ token,++ added_at: Date.now(),++ };++ }++++ function getAnonAccount(id) {++ if (id) return getAccounts().find(a => a && a.is_anon && String(a.id) === String(id)) || null;++ return getAccounts().find(a => a && a.is_anon) || null;++ }++++ function getAnonAccounts() {++ return getAccounts().filter(a => a && a.is_anon);++ }++++ // ─── Account Store ─────────────────────────────────────────++ function getAccounts() {++ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }++ }++++ function saveAccounts(accounts) {++ localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts));++ }++++ function getActiveId() {++ return localStorage.getItem(ACTIVE_KEY);++ }++++ function setActiveId(id) {++ localStorage.setItem(ACTIVE_KEY, id);++ }++++ function addAccount(user, token) {++ const accounts = getAccounts();++ const entry = normalizeAccount(user, token);++ const existing = accounts.findIndex(a => String(a.id) === String(entry.id));++ if (existing !== -1) {++ accounts[existing] = entry;++ } else {++ if (accounts.length >= MAX_ACCOUNTS) {++ accounts.shift(); // Remove oldest++ }++ accounts.push(entry);++ }++ saveAccounts(accounts);++ return accounts;++ }++++ function removeAccount(id) {++ const targetId = String(id);++ const accounts = getAccounts().filter(a => String(a.id) !== targetId);++ saveAccounts(accounts);++ // Clean up anon token if removing an anon identity++ if (isAnonId(targetId)) removeAnonToken(targetId);++ if (getActiveId() === targetId) {++ const fallback = accounts.find(a => !a.is_anon) || accounts[0];++ if (fallback) switchTo(fallback.id);++ else logout();++ }++ return accounts;++ }++++ /** Read ov_token from the cookie (host-only; Domain=.openvibe.tools on tools hosts) */++ function getCookieToken() {++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);++ return m ? decodeURIComponent(m[1]) : null;++ }++++ // ─── API ───────────────────────────────────────────────────++ async function apiFetch(path, opts = {}) {++ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();++ const headers = { 'Content-Type': 'application/json', ...opts.headers };++ if (token) headers.Authorization = `Bearer ${token}`;++ const res = await fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: 'include' });++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);++ return res.json();++ }++++ async function switchTo(accountId) {++ if (isAnonId(accountId)) return switchToAnon(accountId);++++ const accounts = getAccounts();++ const target = accounts.find(a => String(a.id) === String(accountId));++ if (target?.is_anon) return switchToAnon(accountId);++ if (!target || !target.token) {++ // Token expired — need re-login++ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;++ return;++ }++++ // Validate token is still good++ try {++ const headers = { Authorization: `Bearer ${target.token}` };++ const res = await fetch(`${_config.apiBase}/api/auth/me`, { headers });++ if (!res.ok) throw new Error('Token invalid');++ } catch {++ // Token expired++ removeAccount(target.id);++ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;++ return;++ }++++ localStorage.setItem(TOKEN_KEY, target.token);++ setActiveId(target.id);++ setAuthCookie(target.token);++++ if (_config.onSwitch) _config.onSwitch(target);++ else window.location.reload();++ }++++ /**++ * Switch to an anonymous identity. If anonId is provided, switch to that++ * specific anon. Otherwise use the most recently active or create a new one.++ */++ async function switchToAnon(anonId) {++ async function fetchAnonUser(token) {++ const res = await fetch(`${_config.apiBase}/api/auth/anon/${encodeURIComponent(token)}`, { credentials: 'include' });++ if (!res.ok) throw new Error('Anon session invalid');++ const data = await res.json();++ return data.user || null;++ }++++ let anonToken = null;++ let anonUser = null;++++ // If switching to a specific anon identity, look up its token++ if (anonId && anonId !== 'anon') {++ anonToken = getAnonToken(anonId);++ }++++ // Fallback to legacy single-anon token++ if (!anonToken) {++ anonToken = localStorage.getItem(ANON_KEY);++ }++++ // Try to validate the existing token++ if (anonToken) {++ try {++ anonUser = await fetchAnonUser(anonToken);++ } catch {++ // Token invalid — clear it++ if (anonId) removeAnonToken(anonId);++ localStorage.removeItem(ANON_KEY);++ anonToken = null;++ }++ }++++ // Create new anon session if we don't have a valid one++ if (!anonToken) {++ try {++ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: '{}',++ credentials: 'include',++ }).then(async (res) => {++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);++ return res.json();++ });++ anonToken = data.token;++ anonUser = data.user || null;++ } catch (err) {++ console.error('[OpenVibeAccountSwitcher] Failed to create anon session:', err);++ return;++ }++ }++++ if (!anonUser) {++ try {++ anonUser = await fetchAnonUser(anonToken);++ } catch (err) {++ console.error('[OpenVibeAccountSwitcher] Failed to load anon session:', err);++ return;++ }++ }++++ // Store the anon account and its token++ if (anonUser) {++ const entry = addAccount(anonUser, anonToken);++ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';++ setAnonToken(realId, anonToken);++ // Also keep legacy key for backward compat++ localStorage.setItem(ANON_KEY, anonToken);++++ localStorage.removeItem(TOKEN_KEY);++ setActiveId(realId);++ clearAuthCookie();++++ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number || null });++ else window.location.reload();++ }++ }++++ /**++ * Create a brand-new anonymous identity (even if one already exists).++ * Useful for the "New Anon Identity" button.++ */++ async function createNewAnon() {++ try {++ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ force_new: true }),++ credentials: 'include',++ }).then(async (res) => {++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);++ return res.json();++ });++ const anonToken = data.token;++ const anonUser = data.user;++ if (!anonUser) throw new Error('No user returned');++++ const entry = addAccount(anonUser, anonToken);++ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';++ setAnonToken(realId, anonToken);++++ localStorage.removeItem(TOKEN_KEY);++ localStorage.setItem(ANON_KEY, anonToken);++ setActiveId(realId);++ clearAuthCookie();++++ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number });++ else window.location.reload();++ } catch (err) {++ console.error('[OpenVibeAccountSwitcher] Failed to create new anon:', err);++ }++ }++++ /** 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);++ localStorage.removeItem(ACTIVE_KEY);++ clearAuthCookie();++ if (_config.onSwitch) _config.onSwitch(null);++ else window.location.reload();++ }++++ function logoutAll() {++ serverLogout();++ localStorage.removeItem(STORAGE_KEY);++ localStorage.removeItem(TOKEN_KEY);++ localStorage.removeItem(ACTIVE_KEY);++ localStorage.removeItem(ANON_KEY);++ localStorage.removeItem(ANON_TOKENS_KEY);++ clearAuthCookie();++ if (_config.onSwitch) _config.onSwitch(null);++ else window.location.reload();++ }++++ // ─── Switcher Panel UI ─────────────────────────────────────++ function injectStyles() {++ if (document.getElementById('openvibe-switcher-styles')) return;++ const s = document.createElement('style');++ s.id = 'openvibe-switcher-styles';++ s.textContent = `++ .openvibe-switcher-overlay {++ position: fixed; inset: 0; z-index: 20000;++ background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);++ display: flex; align-items: center; justify-content: center;++ animation: openvibe-sw-fadein .2s ease;++ }++ @keyframes openvibe-sw-fadein { from { opacity: 0; } to { opacity: 1; } }++++ .openvibe-switcher-panel {++ width: 380px; max-height: 520px;++ background: var(--bg-card, #22222c);++ border: 1px solid var(--border, #333340); border-radius: 16px;++ box-shadow: 0 20px 60px rgba(0,0,0,0.6);++ overflow: hidden; animation: openvibe-sw-pop .25s cubic-bezier(.34,1.56,.64,1);++ }++ @keyframes openvibe-sw-pop { from { transform: scale(.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }++++ .openvibe-switcher-header {++ text-align: center; padding: 20px 16px 12px;++ border-bottom: 1px solid var(--border, #333340);++ }++ .openvibe-switcher-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; color: var(--text-primary, #e0e0e0); }++ .openvibe-switcher-header p { margin: 0; font-size: 12px; color: var(--text-muted, #707080); }++++ .openvibe-switcher-list { padding: 8px; max-height: 300px; overflow-y: auto; }++++ .openvibe-switcher-item {++ display: flex; align-items: center; gap: 12px; padding: 10px 12px;++ border-radius: 10px; cursor: pointer; transition: background .15s;++ position: relative;++ }++ .openvibe-switcher-item:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-switcher-item.active { background: rgba(192,150,92,0.08); }++ .openvibe-switcher-item.active::after {++ content: '✓'; position: absolute; right: 12px; top: 50%; transform: translateY(-50%);++ color: var(--accent, #3b82f6); font-weight: 700; font-size: 16px;++ }++ .openvibe-switcher-item img { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }++ .openvibe-switcher-item .info { flex: 1; line-height: 1.3; }++ .openvibe-switcher-item .info .name { font-size: 14px; font-weight: 600; color: var(--text-primary, #e0e0e0); }++ .openvibe-switcher-item .info .sub { font-size: 11px; color: var(--text-muted, #707080); }++ .openvibe-switcher-item .remove {++ opacity: 0; font-size: 14px; color: var(--live-red, #e74c3c);++ cursor: pointer; padding: 4px 6px; border-radius: 4px;++ transition: opacity .15s, background .15s;++ }++ .openvibe-switcher-item:hover .remove { opacity: .6; }++ .openvibe-switcher-item .remove:hover { opacity: 1; background: rgba(231,76,60,0.1); }++++ .openvibe-switcher-anon {++ display: flex; align-items: center; gap: 12px; padding: 10px 12px;++ margin: 0 8px 8px; border-radius: 10px; cursor: pointer;++ border: 1px dashed var(--border, #333340);++ transition: all .15s; color: var(--text-muted, #707080);++ }++ .openvibe-switcher-anon:hover { background: var(--bg-hover, #2f2f3d); border-color: var(--accent-dark, #a07840); }++ .openvibe-switcher-anon .anon-icon { font-size: 22px; width: 40px; text-align: center; }++++ .openvibe-switcher-actions {++ padding: 8px 12px; border-top: 1px solid var(--border, #333340);++ display: flex; gap: 8px; justify-content: center;++ }++ .openvibe-switcher-actions button {++ flex: 1; padding: 8px; border: none; border-radius: 8px;++ font-size: 12px; font-weight: 600; cursor: pointer;++ transition: all .15s;++ }++ .openvibe-switcher-actions .btn-add {++ background: var(--accent, #3b82f6); color: #fff;++ }++ .openvibe-switcher-actions .btn-add:hover { background: var(--accent-dark, #a07840); }++ .openvibe-switcher-actions .btn-signout {++ background: rgba(231,76,60,0.1); color: var(--live-red, #e74c3c);++ }++ .openvibe-switcher-actions .btn-signout:hover { background: rgba(231,76,60,0.2); }++ `;++ document.head.appendChild(s);++ }++++ function showPanel() {++ if (_panelEl) return;++ injectStyles();++++ const accounts = getAccounts();++ const activeId = getActiveId();++ const isAnonActive = isAnonId(activeId);++ const loggedAccounts = accounts.filter(a => !a.is_anon);++ const anonAccounts = accounts.filter(a => a.is_anon);++++ const overlay = document.createElement('div');++ overlay.className = 'openvibe-switcher-overlay';++ overlay.addEventListener('click', e => {++ if (e.target === overlay) closePanel();++ });++++ const panel = document.createElement('div');++ panel.className = 'openvibe-switcher-panel';++++ panel.innerHTML = `++ <div class="openvibe-switcher-header">++ <h3>\uD83D\uDD25 Switch Account</h3>++ <p>Choose an account or browse anonymously</p>++ </div>++ <div class="openvibe-switcher-list">++ ${loggedAccounts.map(a => `++ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">++ <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="">++ <div class="info">++ <div class="name">${a.display_name || a.username}</div>++ <div class="sub">${a.email || `@${a.username}`}</div>++ </div>++ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}++ </div>++ `).join('')}++++ ${anonAccounts.length > 0 ? `++ <div style="padding:6px 12px 2px;font-size:11px;font-weight:600;color:var(--text-muted,#707080);text-transform:uppercase;letter-spacing:0.05em;">Anonymous Identities</div>++ ${anonAccounts.map(a => `++ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">++ <span style="width:40px;height:40px;display:flex;align-items:center;justify-content:center;font-size:20px;border-radius:50%;background:var(--bg-secondary,#1a1a26);flex-shrink:0;">\uD83E\uDEE5</span>++ <div class="info">++ <div class="name">Anonymous #${a.anon_number || '?'}</div>++ <div class="sub">anon_${a.anon_number || '?'}</div>++ </div>++ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove identity">\u2715</span>` : ''}++ </div>++ `).join('')}++ ` : ''}++ </div>++ <div class="openvibe-switcher-anon" id="openvibe-sw-new-anon">++ <span class="anon-icon">+</span>++ <div class="info">++ <div class="name" style="color:var(--text-primary,#e0e0e0)">${anonAccounts.length > 0 ? 'New Anon Identity' : 'Go Anonymous'}</div>++ <div class="sub">${anonAccounts.length > 0 ? 'Create another anonymous identity' : 'Browse without an account \u00b7 Limited features'}</div>++ </div>++ </div>++ <div class="openvibe-switcher-actions">++ <button class="btn-add" id="openvibe-sw-add">+ Add Account</button>++ <button class="btn-signout" id="openvibe-sw-signout">Sign Out All</button>++ </div>++ `;++++ overlay.appendChild(panel);++ document.body.appendChild(overlay);++ _panelEl = overlay;++++ // Wire events++ panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {++ el.addEventListener('click', e => {++ if (e.target.closest('.remove')) return;++ switchTo(el.dataset.id);++ closePanel();++ });++ });++++ panel.querySelectorAll('[data-remove-id]').forEach(el => {++ el.addEventListener('click', e => {++ e.stopPropagation();++ removeAccount(el.dataset.removeId);++ closePanel();++ showPanel(); // Re-render++ });++ });++++ panel.querySelector('#openvibe-sw-new-anon').addEventListener('click', () => {++ if (anonAccounts.length > 0) {++ createNewAnon();++ } else {++ switchToAnon();++ }++ closePanel();++ });++++ panel.querySelector('#openvibe-sw-add').addEventListener('click', () => {++ window.location.href = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;++ });++++ panel.querySelector('#openvibe-sw-signout').addEventListener('click', () => {++ logoutAll();++ closePanel();++ });++++ // Esc to close++ const escHandler = e => { if (e.key === 'Escape') closePanel(); };++ document.addEventListener('keydown', escHandler);++ overlay._escHandler = escHandler;++ }++++ function closePanel() {++ if (!_panelEl) return;++ document.removeEventListener('keydown', _panelEl._escHandler);++ _panelEl.remove();++ _panelEl = null;++ }++++ // ─── Token Refresh ───────────────────────────────────────────++ // Decode JWT payload without verification (client-side can't verify RS256).++ // Used only to check `exp` claim for proactive refresh.++ function decodeJwtPayload(token) {++ try {++ const parts = token.split('.');++ if (parts.length !== 3) return null;++ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');++ return JSON.parse(atob(payload));++ } catch { return null; }++ }++++ let _refreshTimer = null;++ let _refreshInProgress = false;++++ /**++ * Silently refresh the JWT if it expires within the next 2 hours,++ * or if it has already expired (7-day grace period on the server).++ * Updates localStorage, cookie, and account list.++ */++ async function silentRefresh() {++ if (_refreshInProgress) return;++ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();++ if (!token) return;++ const payload = decodeJwtPayload(token);++ if (!payload || !payload.exp) return;++++ const expiresAt = payload.exp * 1000;++ const now = Date.now();++ const twoHours = 2 * 60 * 60 * 1000;++++ // Only refresh if token expires within 2 hours or is already expired++ if (expiresAt - now > twoHours) return;++++ _refreshInProgress = true;++ try {++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {++ method: 'POST',++ headers: {++ 'Content-Type': 'application/json',++ Authorization: `Bearer ${token}`,++ },++ credentials: 'include',++ });++ if (!res.ok) {++ // If refresh fails with 401, token is truly dead — don't retry++ if (res.status === 401) {++ console.warn('[OpenVibeAuth] Token refresh failed — session expired');++ }++ return;++ }++ const data = await res.json();++ if (data.token) {++ // Update localStorage++ localStorage.setItem(TOKEN_KEY, data.token);++ // Update cookie++ setAuthCookie(data.token);++ // Update the stored account entry++ const activeId = getActiveId();++ if (activeId && !isAnonId(activeId)) {++ const accounts = getAccounts();++ const idx = accounts.findIndex(a => String(a.id) === String(activeId));++ if (idx !== -1) {++ accounts[idx].token = data.token;++ saveAccounts(accounts);++ }++ }++ console.log('[OpenVibeAuth] Token refreshed silently');++ }++ } catch (err) {++ console.warn('[OpenVibeAuth] Silent refresh error:', err.message);++ } finally {++ _refreshInProgress = false;++ }++ }++++ /**++ * Start a periodic timer that checks token expiry and refreshes when needed.++ * Checks every 15 minutes. Also runs an immediate check.++ */++ function startRefreshTimer() {++ if (_refreshTimer) return;++ silentRefresh(); // Immediate check++ _refreshTimer = setInterval(silentRefresh, 15 * 60 * 1000); // Every 15 min++ }++++ function stopRefreshTimer() {++ if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; }++ }++++ // ─── Listen for navbar events ──────────────────────────────++ document.addEventListener('openvibe-switch-account', e => {++ switchTo(e.detail.accountId);++ });++++ // ─── Public API ────────────────────────────────────────────++ const OpenVibeAccountSwitcher = {++ init(opts = {}) {++ Object.assign(_config, opts);++ // Start silent token refresh for logged-in users++ const activeId = getActiveId();++ if (activeId && !isAnonId(activeId)) {++ startRefreshTimer();++ }++ },++++ /** Call after login success to store this account. */++ addAccount,++ removeAccount,++ getAccounts,++ getActiveId,++ switchTo,++ switchToAnon,++ createNewAnon,++ getAnonAccounts,++ logout,++ logoutAll,++ silentRefresh,++ startRefreshTimer,++ stopRefreshTimer,++++ /** Show the full-screen account switcher panel. */++ showPanel,++ closePanel,++++ /** Quick check: is user on an anonymous session? */++ isAnonymous() {++ const id = getActiveId();++ return isAnonId(id);++ },++++ /** Get the current active account object. */++ getActive() {++ const id = getActiveId();++ if (!id) return null;++ if (isAnonId(id)) return getAnonAccount(id) || getAnonAccount() || null;++ return getAccounts().find(a => String(a.id) === String(id)) || null;++ },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeAccountSwitcher;++ else root.OpenVibeAccountSwitcher = OpenVibeAccountSwitcher;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,445 @@++/**++ * 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 root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});++ // Where this file was served from: shipped.js is loaded from the same place (the site's own++ // /shared/ copy, or openvibe.network), so the site's CSP already allows it.++ const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })();++ // ── Frame data (https://openvibe.network/api/frame) ─────────────────────────────++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.++ const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {++ const KEY = 'ov_frame_v1', TTL = 30 * 60000;++ let inflight = null;++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };++ function clean(d) {++ if (!d || !Array.isArray(d.nav)) return null;++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const f = d.footer || {}, lg = f.legal || {};++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };++ }++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }++ function refresh() {++ if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call out++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);++ inflight = fetch('https://openvibe.network/api/frame?host=' + encodeURIComponent(host()), { credentials: 'omit' })++ .then(r => (r.ok ? r.json() : null)).then(clean)++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })++ .catch(() => null);++ return inflight;++ }++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */++ function get(onFirst) {++ const c = cached();++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }++ return c ? c.data : null;++ }++ return { get, refresh };++ })());++++ const NETWORK_URL = 'https://openvibe.network';++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = [++ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },++ { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },++ { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },++ ];++++ const SOCIAL = [++ { name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' },++ { name: 'Discord', icon: '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',++ shipped: true, // the "shipped X ago" line (openvibe-shared/shipped.js)++ updates: null, // this site's update log; default: openvibe.network/updates?site=<host>++ };++++ 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';++ if (/(^|\.)openre\.stream$/.test(h)) return 'openre';++ const tld = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);++ if (tld && tld[1] !== 'tools' && TLD_LABELS[tld[1]]) return tld[1];++ 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) {++ // Each site answers for itself: the Frame service names this domain's own documents.++ const frame = cfg._frame;++ if (frame && frame.footer.legal && !cfg.legalBase) return frame.footer.legal[item.key];++ // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.++ if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;++ if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;++ return item.fallback;++ }++++ // ── Icons ──────────────────────────────────────────────────++ // The footer ships its own icons. It used to borrow the page's Font Awesome, so on pages that do not++ // load that font (the server-rendered Tools and legal pages) every icon was an empty box.++ const ICONS = {++ github: '<path fill="currentColor" stroke="none" d="M12 2.2a9.8 9.8 0 0 0-3.1 19.1c.5.1.7-.2.7-.5v-1.7c-2.7.6-3.3-1.2-3.3-1.2-.4-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.2-.2-4.5-1.1-4.5-4.8 0-1.1.4-1.9 1-2.6-.1-.2-.4-1.2.1-2.6 0 0 .8-.3 2.7 1a9.3 9.3 0 0 1 4.9 0c1.9-1.3 2.7-1 2.7-1 .5 1.4.2 2.4.1 2.6.6.7 1 1.5 1 2.6 0 3.7-2.3 4.6-4.5 4.8.4.3.7.9.7 1.800v2.700c0 .3.2.6.7.5A9.8 9.8 0 0 0 12 2.200z"/>',++ discord: '<path fill="currentColor" stroke="none" d="M19.3 5.300a16.5 16.5 0 0 0-4.1-1.300l-.5 1a15.3 15.3 0 0 0-5.4 0l-.5-1a16.5 16.5 0 0 0-4.1 1.300C2.1 9.2 1.4 13 1.7 16.700a16.6 16.6 0 0 0 5.1 2.600l1.1-1.800c-.6-.2-1.2-.5-1.7-.8l.4-.3a11.8 11.8 0 0 0 10.8 0l.4.3c-.5.3-1.1.6-1.7.8l1.1 1.800a16.6 16.6 0 0 0 5.1-2.600c.4-4.3-.7-8.1-3-11.400zM8.7 14.400c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100zm6.6 0c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100z"/>',++ sitemap: '<rect x="9" y="3.5" width="6" height="4.5" rx="1"/><rect x="3" y="16" width="6" height="4.5" rx="1"/><rect x="15" y="16" width="6" height="4.5" rx="1"/><path d="M12 8v4M6 16v-2.500h12V16"/>',++ arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',++ channel: '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.800a6 6 0 0 0 0 8.400M16.2 7.800a6 6 0 0 1 0 8.400M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',++ dashboard: '<path d="M4.5 17a8 8 0 1 1 15 0M12 13l3.5-4"/><circle cx="12" cy="13.5" r="1.3"/>',++ account: '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>',++ themes: '<path d="M12 4.500a7.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.300h1.600a3.2 3.2 0 0 0 3.2-3.200C19.5 8 16.2 4.5 12 4.500z"/><circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',++ tools: '<path d="M14.5 5.500a4 4 0 0 0-5.2 5.200L4.5 15.500a1.8 1.8 0 0 0 2.5 2.500l4.8-4.800a4 4 0 0 0 5.2-5.200l-2.6 2.6-2.1-.5-.5-2.100z"/>',++ };++ const icon = (name) => `<svg class="ovf-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.800" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${(ICONS[name] || ICONS.arrow)}</svg>`;++++ // ── Markup ─────────────────────────────────────────────────++ function linkTag(raw) {++ // Items are { name, url }; { label, href } (the navbar's shape) is read the same, so a site that++ // passes it does not get empty links.++ const item = { ...raw, name: raw.name ?? raw.label, url: raw.url ?? raw.href };++ // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.++ const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);++ const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : '';++ const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';++ const title = item.desc ? ` title="${esc(item.desc)}"` : '';++ return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;++ }++++ /** The "🚀 shipped X ago: …" line, filled by shipped.js from the network changelog; hidden until then. */++ function shippedLine(c) {++ if (c.shipped === false) return '';++ const host = typeof location !== 'undefined' ? location.hostname : '';++ const updates = c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;++ return `<a class="ovf-shipped" data-ov-shipped="latest" data-service="${esc(c.service || 'auto')}" href="${esc(updates)}" hidden></a>`;++ }++ function updatesHref(c) {++ const host = typeof location !== 'undefined' ? location.hostname : '';++ return c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;++ }++++ function buildHTML(cfg) {++ const c = { ...DEFAULTS, ...(cfg || {}) };++ const service = c.service || detectService();++ const brand = c.brandName || brandFor(service);++ const frame = c._frame = OVFrame.get(() => { try { render(); } catch { /* */ } });++ // Most used sites first when the Frame service has spoken; the built-in list otherwise.++ const network = frame && frame.nav.length++ ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))++ : NETWORK.filter(n => n.id !== service);++ const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline;++ const popular = frame ? frame.footer.popular : [];++ const discover = frame ? frame.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];++ 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>++ ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}++ </nav>++ ${shippedLine(c)}++ <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(tagline)}</p>++ <div class="ovf-social">++ ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}">${icon(s.icon)}</a>`).join('')}++ <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap">${icon('sitemap')}</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 ${icon('arrow')}</a>++ </nav>` : ''}++ ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">++ <h3>Popular tools</h3>++ ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}++ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>++ </nav>` : ''}++ <nav class="ovf-col" aria-label="Legal">++ <h3>Legal</h3>++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}++ </nav>++ </div>++ <div class="ovf-account" id="ovf-account" hidden></div>++ <div class="ovf-bar">++ <span class="ovf-copy">Open source & community driven · built in the open</span>++ ${shippedLine(c)}++ <span class="ovf-legal-inline">++ ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}++ ${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: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' },++ { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'account' },++ { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' },++ { name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' },++ ];++ 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"' : ''}>${icon(q.icon)} ${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,#60a5fa);transform:translateX(2px)}++.ovf-more{font-weight:600;opacity:.85}.ovf-ic{flex:none;vertical-align:-.14em}.ovf-more .ovf-ic{font-size:.85em;margin-left:3px;transition:transform .15s}.ovf-more:hover .ovf-ic{transform:translateX(3px)}.ovf-social .ovf-ic{font-size:17px}.ovf-account-links .ovf-ic{font-size:1.05em;opacity:.85}++.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,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-2px)}++.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;++ margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;++ background:color-mix(in srgb, var(--accent,#60a5fa) 8%, var(--bg-tertiary,#1a1b28));++ border:1px solid color-mix(in srgb, var(--accent,#60a5fa) 26%, var(--border,rgba(255,255,255,.08)));++ animation:ovfIn .4s ease-out both}++@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}++.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}++.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}++.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;++ background:var(--accent,#60a5fa);color:#fff;font-size:.7rem;font-weight:800;flex:none}++.ovf-avatar img{width:100%;height:100%;object-fit:cover}++.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}++.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;++ color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));++ text-decoration:none;transition:color .15s,border-color .15s,transform .15s}++.ovf-account-links a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-1px)}++.ovf-account-links i{font-size:.72rem;opacity:.8}++.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;++ margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));++ 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 [hidden],.ovf-account[hidden]{display:none!important}++.ovf .ovf-shipped.ov-shipped-latest{font-size:.76rem;padding:4px 12px;max-width:min(520px,100%);background:transparent}++.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,#60a5fa)}++.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}++@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}++@media (max-width:560px){++ .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}}++.ovf{-webkit-tap-highlight-color:transparent}++:where(.ovf) :where(a,button):active{scale:.97}++:where(.ovf) :where(a,button):focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:2px;border-radius:4px}++@media (prefers-reduced-motion:reduce){:where(.ovf) :where(a,button):active{scale:none;opacity:.82}}`;++++ 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);++ if (_cfg.shipped !== false) loadShipped();++ }++++ // shipped.js mounts every [data-ov-shipped] element when it loads; loaded once, lazily, after the footer.++ let _shippedLoading = false;++ function loadShipped() {++ if (typeof document === 'undefined') return;++ if (root.OpenVibeShipped && typeof root.OpenVibeShipped.scan === 'function') { try { root.OpenVibeShipped.scan(document); } catch { /* */ } return; }++ if (_shippedLoading || document.querySelector('script[data-ov-shipped-src]')) return;++ _shippedLoading = true;++ const sc = document.createElement('script');++ let src = `${NETWORK_URL}/shared/shipped.js`;++ try { if (OWN_SRC) src = new URL('shipped.js', OWN_SRC).href; } catch { /* */ }++ sc.src = src;++ sc.defer = true;++ sc.setAttribute('data-ov-shipped-src', '');++ (document.head || document.documentElement).appendChild(sc);++ }++++ 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();++ }++++ /**++ * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for++ * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element.++ * ssr({ service, variant, links, mountId }) → '<style>…</style><footer id="ov-footer" …>…</footer>'++ */++ function ssr(cfg = {}) {++ const c = { ...DEFAULTS, ...cfg };++ const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, '');++ return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;++ }++++ return { init, ssr, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };++});nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -0,0 +1,105 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — navbar icon glyphs. Generated by scripts/build-nav-icons.py; do not edit by hand.++// Font Awesome Free 6 solid glyphs (CC BY 4.0, fontawesome.com/license/free) as filled paths:++// name -> [advance width, path] in a 0 0 <w> 512 viewBox. navbar.js ships the glyphs it draws++// itself and fetches this file after first paint for the rest; a page that wants every glyph++// from the first frame can load it before navbar.js instead.++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ const NAV_ICONS = {++ 'fa-bell': [448, 'M224 11Q210 11 201 20Q192 29 192 43V62Q136 74 101 117Q65 160 64 219V238Q63 310 16 365L8 374Q-5 389 3 408Q12 426 32 427H416Q436 426 445 408Q453 389 440 374L433 365Q385 311 384 238V219Q383 160 347 117Q312 74 256 62V43Q256 29 247 20Q238 11 224 11ZM269 504Q288 485 288 459H224H160Q160 485 179 504Q198 523 224 523Q250 523 269 504Z'],++ 'fa-book': [448, 'M96 11Q55 12 28 39Q1 66 0 107V427Q1 468 28 495Q55 522 96 523H384H416Q430 523 439 514Q448 505 448 491Q448 477 439 468Q430 459 416 459V395Q430 395 439 386Q448 377 448 363V43Q448 29 439 20Q430 11 416 11H384ZM96 395H352H96H352V459H96Q82 459 73 450Q64 441 64 427Q64 413 73 404Q82 395 96 395ZM128 155Q129 140 144 139H336Q351 140 352 155Q351 170 336 171H144Q129 170 128 155ZM144 203H336H144H336Q351 204 352 219Q351 234 336 235H144Q129 234 128 219Q129 204 144 203Z'],++ 'fa-camera': [512, 'M149 76 139 107 149 76 139 107H64Q37 108 19 126Q1 144 0 171V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V171Q511 144 493 126Q475 108 448 107H373L363 76Q351 45 317 43H195Q161 45 149 76ZM256 203Q282 203 304 216Q326 229 339 251Q352 274 352 299Q352 324 339 347Q326 369 304 382Q282 395 256 395Q230 395 208 382Q186 369 173 347Q160 324 160 299Q160 274 173 251Q186 229 208 216Q230 203 256 203Z'],++ 'fa-clock-rotate-left': [512, 'M75 86 41 52 75 86 41 52Q29 41 15 47Q1 52 0 69V179Q2 201 24 203H134Q151 202 156 188Q162 174 151 162L120 131Q174 77 256 75Q338 77 392 131Q446 185 448 267Q446 349 392 403Q338 457 256 459Q194 458 146 425Q135 417 122 419Q110 422 102 433Q94 444 96 456Q99 469 110 477Q173 522 256 523Q328 522 385 488Q443 454 477 396Q511 339 512 267Q511 195 477 138Q443 80 385 46Q328 12 256 11Q203 11 156 31Q110 51 75 86ZM256 139Q234 141 232 163V267Q232 277 239 284L311 356Q328 370 345 356Q359 339 345 322L280 257V163Q278 141 256 139Z'],++ 'fa-coins': [512, 'M512 91Q511 118 474 139Q428 164 351 170Q346 167 340 165Q279 140 192 139Q180 139 168 140L166 139Q129 118 128 91Q130 57 184 34Q238 12 320 11Q402 12 456 34Q510 57 512 91ZM161 172Q176 171 192 171Q239 171 278 180Q318 188 345 202Q383 223 384 251Q384 257 382 263Q374 283 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q346 299 346 299Q346 299 346 299Q319 314 279 322Q240 331 192 331Q100 330 44 302Q41 300 38 299Q1 278 0 251Q1 224 36 204Q71 184 128 176Q144 173 161 172ZM416 251Q414 218 392 198Q435 191 468 177Q493 167 512 152V187Q511 217 468 238Q446 249 416 256Q416 256 416 256Q416 254 416 251ZM384 347Q383 374 346 395Q344 396 342 397Q341 397 340 398Q284 426 192 427Q144 427 105 418Q65 410 38 395Q1 374 0 347V312Q19 327 44 337Q105 362 192 363Q279 362 340 337Q352 332 363 326Q372 321 380 315Q381 314 383 313Q383 312 384 312V315V321V347ZM416 347V315V347V315V289Q445 283 468 273Q493 263 512 248V283Q512 299 497 314Q471 339 416 352Q416 350 416 347ZM192 459Q279 458 340 433Q365 423 384 408V443Q382 477 328 500Q274 522 192 523Q110 522 56 500Q2 477 0 443V408Q19 423 44 433Q105 458 192 459Z'],++ 'fa-comments': [640, 'M208 363Q296 361 355 311Q414 262 416 187Q414 112 355 63Q296 13 208 11Q120 13 61 63Q2 112 0 187Q1 246 40 290Q34 305 25 315Q18 324 12 329Q9 332 8 333Q7 334 7 334H6Q-2 341 1 352Q5 362 16 363Q50 362 78 351Q92 345 103 339Q150 362 208 363ZM448 187Q445 273 384 329Q322 385 232 394Q251 451 305 486Q359 522 432 523Q490 522 537 499Q548 505 562 511Q590 522 624 523Q635 522 639 512Q642 501 633 494Q633 494 633 493Q632 493 632 493Q631 492 628 489Q622 484 615 475Q606 464 600 450Q639 406 640 347Q638 276 584 227Q530 178 447 171Q448 179 448 187Z'],++ 'fa-gamepad': [640, 'M192 75Q110 77 56 131Q2 185 0 267Q2 349 56 403Q110 457 192 459H448Q530 457 584 403Q638 349 640 267Q638 185 584 131Q530 77 448 75H192ZM496 179Q519 180 531 199Q541 219 531 239Q519 258 496 259Q473 258 461 239Q451 219 461 199Q473 180 496 179ZM392 315Q393 292 412 280Q432 270 452 280Q471 292 472 315Q471 338 452 350Q432 360 412 350Q393 338 392 315ZM168 211Q170 189 192 187Q214 189 216 211V243H248Q270 245 272 267Q270 289 248 291H216V323Q214 345 192 347Q170 345 168 323V291H136Q114 289 112 267Q114 245 136 243H168V211Z'],++ 'fa-gauge-high': [512, 'M0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267ZM288 107Q288 93 279 84Q270 75 256 75Q242 75 233 84Q224 93 224 107Q224 121 233 130Q242 139 256 139Q270 139 279 130Q288 121 288 107ZM256 427Q283 426 301 408Q319 390 320 363Q319 336 302 318L366 173Q373 152 354 141Q333 134 322 153L258 299Q257 299 257 299Q256 299 256 299Q229 300 211 318Q193 336 192 363Q193 390 211 408Q229 426 256 427ZM176 155Q176 141 167 132Q158 123 144 123Q130 123 121 132Q112 141 112 155Q112 169 121 178Q130 187 144 187Q158 187 167 178Q176 169 176 155ZM96 299Q110 299 119 290Q128 281 128 267Q128 253 119 244Q110 235 96 235Q82 235 73 244Q64 253 64 267Q64 281 73 290Q82 299 96 299ZM448 267Q448 253 439 244Q430 235 416 235Q402 235 393 244Q384 253 384 267Q384 281 393 290Q402 299 416 299Q430 299 439 290Q448 281 448 267Z'],++ 'fa-hand-fist': [448, 'M192 11Q206 11 215 20Q224 29 224 43V155H160V43Q160 29 169 20Q178 11 192 11ZM64 75Q64 61 73 52Q82 43 96 43Q110 43 119 52Q128 61 128 75V155H64V75ZM256 75Q256 61 265 52Q274 43 288 43Q302 43 311 52Q320 61 320 75V171Q320 185 311 194Q302 203 288 203Q274 203 265 194Q256 185 256 171V75ZM352 139Q352 125 361 116Q370 107 384 107Q398 107 407 116Q416 125 416 139V203Q416 217 407 226Q398 235 384 235Q370 235 361 226Q352 217 352 203V139ZM256 227V226V227V226Q270 235 288 235Q308 235 324 224Q330 243 347 255Q363 267 384 267Q402 267 416 258V267Q416 307 399 340Q382 373 352 395V491Q352 505 343 514Q334 523 320 523H160Q146 523 137 514Q128 505 128 491V413Q102 401 81 380L70 369Q33 331 32 278V251Q33 224 51 206Q69 188 96 187H184Q201 187 212 199Q224 210 224 227Q224 244 212 255Q201 267 184 267H128Q113 268 112 283Q113 298 128 299H184Q215 298 235 278Q255 258 256 227Z'],++ 'fa-house': [576, 'M576 267Q575 280 566 289Q557 298 544 299H512L513 459Q512 463 512 467V483Q512 500 500 511Q489 523 472 523H456Q454 523 453 523Q451 523 449 523H416H392Q375 523 364 511Q352 500 352 483V459V395Q352 381 343 372Q334 363 320 363H256Q242 363 233 372Q224 381 224 395V459V483Q224 500 212 511Q201 523 184 523H160H128Q127 523 126 523Q125 523 124 523Q122 523 120 523H104Q87 523 76 511Q64 500 64 483V371Q64 370 64 368V299H32Q18 298 9 289Q0 280 0 267Q0 253 10 243L266 19Q277 10 288 11Q300 11 309 18L565 243Q577 253 576 267Z'],++ 'fa-id-card': [576, 'M0 107H576H0H576Q575 80 557 62Q539 44 512 43H64Q37 44 19 62Q1 80 0 107ZM0 139V427V139V427Q1 454 19 472Q37 490 64 491H512Q539 490 557 472Q575 454 576 427V139H0ZM64 416Q65 394 80 379Q95 364 117 363H235Q257 364 272 379Q287 394 288 416Q287 426 277 427H75Q65 426 64 416ZM176 203Q212 204 231 235Q249 267 231 299Q212 330 176 331Q140 330 121 299Q103 267 121 235Q140 204 176 203ZM352 219Q353 204 368 203H496Q511 204 512 219Q511 234 496 235H368Q353 234 352 219ZM352 283Q353 268 368 267H496Q511 268 512 283Q511 298 496 299H368Q353 298 352 283ZM352 347Q353 332 368 331H496Q511 332 512 347Q511 362 496 363H368Q353 362 352 347Z'],++ 'fa-link': [640, 'M580 279Q622 234 622 176Q622 119 580 74Q541 37 489 32Q438 28 394 59L392 60Q381 68 379 81Q377 93 385 105Q393 115 405 117Q418 120 429 112L431 111Q456 94 484 96Q513 98 535 119Q558 144 558 176Q558 209 535 233L422 346Q397 369 365 369Q333 369 308 346Q287 324 285 295Q283 267 300 242L301 240Q308 229 306 217Q304 204 293 196Q282 188 270 190Q257 192 249 203L248 205Q217 249 221 301Q225 352 263 391Q308 433 365 433Q423 433 468 391L580 279ZM60 255Q18 300 18 358Q18 415 60 460Q99 497 151 502Q202 506 247 475L248 474Q259 466 261 453Q263 441 256 429Q247 419 235 417Q222 414 211 422L209 423Q184 440 156 438Q127 436 105 415Q82 390 82 358Q82 325 106 301L218 188Q243 165 275 165Q307 165 332 188Q353 210 355 239Q357 267 340 292L339 294Q332 305 334 318Q336 330 347 338Q358 346 370 344Q383 342 391 331L392 329Q423 285 419 233Q415 182 377 143Q332 101 275 101Q217 101 173 143L60 255Z'],++ 'fa-list-check': [512, 'M152 49Q167 65 154 83L82 163Q75 171 65 171Q54 171 47 164L7 124Q-7 107 7 90Q24 76 41 90L63 112L118 51Q134 36 152 49ZM152 209Q167 225 154 243L82 323Q75 331 65 331Q54 331 47 324L7 284Q-7 267 7 250Q24 236 41 250L63 272L118 211Q134 196 152 209ZM224 107Q224 93 233 84Q242 75 256 75H480Q494 75 503 84Q512 93 512 107Q512 121 503 130Q494 139 480 139H256Q242 139 233 130Q224 121 224 107ZM224 267Q224 253 233 244Q242 235 256 235H480Q494 235 503 244Q512 253 512 267Q512 281 503 290Q494 299 480 299H256Q242 299 233 290Q224 281 224 267ZM160 427Q160 413 169 404Q178 395 192 395H480Q494 395 503 404Q512 413 512 427Q512 441 503 450Q494 459 480 459H192Q178 459 169 450Q160 441 160 427ZM48 379Q75 380 90 403Q102 427 90 451Q75 474 48 475Q21 474 6 451Q-6 427 6 403Q21 380 48 379Z'],++ 'fa-network-wired': [640, 'M256 75H384H256H384V139H256V75ZM240 11Q220 12 206 25Q193 39 192 59V155Q193 175 206 189Q220 202 240 203H288V235H32Q18 235 9 244Q0 253 0 267Q0 281 9 290Q18 299 32 299H128V331H80Q60 332 46 345Q33 359 32 379V475Q33 495 46 509Q60 522 80 523H240Q260 522 274 509Q287 495 288 475V379Q287 359 274 345Q260 332 240 331H192V299H448V331H400Q380 332 366 345Q353 359 352 379V475Q353 495 366 509Q380 522 400 523H560Q580 522 594 509Q607 495 608 475V379Q607 359 594 345Q580 332 560 331H512V299H608Q622 299 631 290Q640 281 640 267Q640 253 631 244Q622 235 608 235H352V203H400Q420 202 434 189Q447 175 448 155V59Q447 39 434 25Q420 12 400 11H240ZM96 459V395V459V395H224V459H96ZM416 395H544H416H544V459H416V395Z'],++ 'fa-palette': [512, 'M512 267Q512 268 512 268Q512 269 512 270Q511 297 490 314Q470 331 442 331H344Q324 332 310 345Q297 359 296 379Q296 384 297 389Q300 403 307 417Q307 418 308 419Q318 439 320 461Q320 485 305 503Q291 521 267 523Q261 523 256 523Q184 522 127 488Q69 454 35 396Q1 339 0 267Q1 195 35 138Q69 80 127 46Q184 12 256 11Q328 12 385 46Q443 80 477 138Q511 195 512 267ZM128 299Q128 285 119 276Q110 267 96 267Q82 267 73 276Q64 285 64 299Q64 313 73 322Q82 331 96 331Q110 331 119 322Q128 313 128 299ZM128 203Q142 203 151 194Q160 185 160 171Q160 157 151 148Q142 139 128 139Q114 139 105 148Q96 157 96 171Q96 185 105 194Q114 203 128 203ZM288 107Q288 93 279 84Q270 75 256 75Q242 75 233 84Q224 93 224 107Q224 121 233 130Q242 139 256 139Q270 139 279 130Q288 121 288 107ZM384 203Q398 203 407 194Q416 185 416 171Q416 157 407 148Q398 139 384 139Q370 139 361 148Q352 157 352 171Q352 185 361 194Q370 203 384 203Z'],++ 'fa-paste': [512, 'M160 11Q123 13 105 43H48Q28 44 14 57Q1 71 0 91V411Q1 431 14 445Q28 458 48 459H192V187Q193 153 215 130Q238 108 272 107H320V91Q319 71 306 57Q292 44 272 43H215Q197 13 160 11ZM272 139Q252 140 238 153Q225 167 224 187V459V475Q225 495 238 509Q252 522 272 523H464Q484 522 498 509Q511 495 512 475V255Q512 235 498 221L430 153Q416 139 396 139H320ZM160 51Q182 53 184 75Q182 97 160 99Q138 97 136 75Q138 53 160 51Z'],++ 'fa-people-arrows': [640, 'M64 75Q65 39 96 20Q128 2 160 20Q191 39 192 75Q191 111 160 130Q128 148 96 130Q65 111 64 75ZM26 244Q29 213 51 192Q74 172 106 171H150Q193 173 216 205Q212 208 208 211L144 275Q128 293 128 315Q128 337 144 355L192 402V475Q191 495 178 509Q164 522 144 523H112Q92 522 78 509Q65 495 64 475V359Q44 352 32 334Q20 316 22 294L26 244ZM448 75Q449 39 480 20Q512 2 544 20Q575 39 576 75Q575 111 544 130Q512 148 480 130Q449 111 448 75ZM432 211Q428 208 424 205Q448 173 490 171H534Q566 172 589 192Q611 213 614 244L618 294Q620 316 608 334Q596 352 576 359V475Q575 495 562 509Q548 522 528 523H496Q476 522 462 509Q449 495 448 475V402L496 355Q512 337 512 315Q512 293 496 275L432 211ZM272 251V283V251V283H368V251Q369 235 383 229Q397 223 409 234L473 298Q487 315 473 332L409 396Q397 406 383 401Q369 395 368 379V347H272V379Q271 395 257 401Q243 407 231 396L167 332Q153 315 167 298L231 234Q243 224 257 229Q271 236 272 251Z'],++ 'fa-people-group': [640, 'M72 99Q73 67 100 51Q128 35 156 51Q183 67 184 99Q183 131 156 147Q128 163 100 147Q73 131 72 99ZM64 257Q49 274 48 299Q49 324 64 341V257ZM208 207Q162 249 160 315Q161 367 192 406V427Q192 441 183 450Q174 459 160 459H96Q82 459 73 450Q64 441 64 427V400Q35 386 18 360Q0 333 0 299Q1 251 33 220Q64 188 112 187H144Q181 188 208 207ZM448 427V406V427V406Q479 367 480 315Q478 249 432 207Q459 188 496 187H528Q576 188 607 220Q639 251 640 299Q640 333 622 360Q605 386 576 400V427Q576 441 567 450Q558 459 544 459H480Q466 459 457 450Q448 441 448 427ZM456 99Q457 67 484 51Q512 35 540 51Q567 67 568 99Q567 131 540 147Q512 163 484 147Q457 131 456 99ZM576 257V341V257V341Q592 324 592 299Q592 274 576 257ZM320 43Q356 44 375 75Q393 107 375 139Q356 170 320 171Q284 170 265 139Q247 107 265 75Q284 44 320 43ZM240 315Q241 340 256 357V273Q241 290 240 315ZM384 273V357V273V357Q400 340 400 315Q400 290 384 273ZM448 315Q448 349 430 376Q413 402 384 416V459Q384 473 375 482Q366 491 352 491H288Q274 491 265 482Q256 473 256 459V416Q227 402 210 376Q192 349 192 315Q193 267 225 236Q256 204 304 203H336Q384 204 415 236Q447 267 448 315Z'],++ 'fa-plus': [448, 'M256 91Q256 77 247 68Q238 59 224 59Q210 59 201 68Q192 77 192 91V235H48Q34 235 25 244Q16 253 16 267Q16 281 25 290Q34 299 48 299H192V443Q192 457 201 466Q210 475 224 475Q238 475 247 466Q256 457 256 443V299H400Q414 299 423 290Q432 281 432 267Q432 253 423 244Q414 235 400 235H256V91Z'],++ 'fa-right-from-bracket': [512, 'M378 117 501 240 378 117 501 240Q512 251 512 267Q512 283 501 294L378 417Q368 427 354 427Q340 427 330 417Q320 407 320 393V331H192Q178 331 169 322Q160 313 160 299V235Q160 221 169 212Q178 203 192 203H320V141Q320 127 330 117Q340 107 354 107Q368 107 378 117ZM160 107H96H160H96Q82 107 73 116Q64 125 64 139V395Q64 409 73 418Q82 427 96 427H160Q174 427 183 436Q192 445 192 459Q192 473 183 482Q174 491 160 491H96Q55 490 28 463Q1 436 0 395V139Q1 98 28 71Q55 44 96 43H160Q174 43 183 52Q192 61 192 75Q192 89 183 98Q174 107 160 107Z'],++ 'fa-scissors': [512, 'M256 203 217 164 256 203 217 164Q224 145 224 123Q223 75 191 44Q160 12 112 11Q64 12 33 44Q1 75 0 123Q1 171 33 202Q64 234 112 235Q134 235 153 228L192 267L153 307Q134 299 112 299Q64 300 33 332Q1 363 0 411Q1 459 33 490Q64 522 112 523Q160 522 191 490Q223 459 224 411Q224 389 217 371L499 88Q510 75 499 62Q477 41 448 41Q419 41 397 62L256 203ZM279 354 397 472 279 354 397 472Q419 493 448 493Q477 493 499 472Q510 459 499 446L343 290L279 354ZM64 123Q65 96 88 81Q112 69 136 81Q159 96 160 123Q159 150 136 165Q112 177 88 165Q65 150 64 123ZM112 363Q139 364 154 387Q166 411 154 435Q139 458 112 459Q85 458 70 435Q58 411 70 387Q85 364 112 363Z'],++ 'fa-screwdriver-wrench': [512, 'M79 16Q62 5 47 18L7 58Q-6 73 5 90L85 194Q92 203 104 203H158L267 312Q256 334 260 359Q263 383 281 402L393 514Q403 523 416 523Q429 523 439 514L503 450Q512 440 512 427Q512 414 503 404L391 292Q372 274 348 271Q323 267 301 278L192 169V115Q192 103 183 96L79 16ZM20 407Q0 427 0 455Q1 484 20 503Q39 522 68 523Q96 523 116 503L234 385Q222 353 230 320L168 258L20 407ZM512 155Q512 139 509 125Q506 116 499 114Q491 113 485 119L421 182Q416 187 409 187H352Q337 186 336 171V114Q336 107 341 102L405 38Q411 32 409 24Q407 17 399 14Q384 11 368 11Q307 13 266 53Q226 94 224 155V156L309 241Q337 234 364 241Q392 249 413 270L429 286Q466 268 489 233Q511 199 512 155ZM56 443Q58 421 80 419Q102 421 104 443Q102 465 80 467Q58 465 56 443Z'],++ 'fa-shield-halved': [512, 'M256 11Q263 11 269 14L458 94Q475 101 485 116Q496 131 496 151Q497 202 479 271Q461 340 414 405Q368 471 282 514Q256 526 230 514Q144 471 98 405Q51 340 33 271Q15 202 16 151Q16 131 27 116Q37 101 54 94L243 14Q249 11 256 11ZM256 78V456V78V456Q324 421 363 367Q401 313 417 256Q432 198 432 152L256 78Z'],++ 'fa-shirt': [640, 'M212 11Q224 12 229 24Q238 54 263 72Q287 90 320 91Q353 90 377 72Q402 54 412 24Q416 12 428 11H441Q475 11 502 33L629 138Q639 147 640 161Q641 174 632 184L576 248Q567 258 555 259Q542 260 532 252L480 209V459Q479 486 461 504Q443 522 416 523H224Q197 522 179 504Q161 486 160 459V209L109 252Q98 260 86 259Q73 258 64 248L8 184Q-1 174 0 161Q1 147 12 138L138 33Q165 11 199 11H212Z'],++ 'fa-text-height': [576, 'M64 139V107V139V107H128V427H96Q82 427 73 436Q64 445 64 459Q64 473 73 482Q82 491 96 491H224Q238 491 247 482Q256 473 256 459Q256 445 247 436Q238 427 224 427H192V107H256V139Q256 153 265 162Q274 171 288 171Q302 171 311 162Q320 153 320 139V91Q319 71 306 57Q292 44 272 43H160H48Q28 44 14 57Q1 71 0 91V139Q0 153 9 162Q18 171 32 171Q46 171 55 162Q64 153 64 139ZM503 52Q493 43 480 43Q467 43 457 52L393 116Q379 132 386 151Q395 170 416 171H448V363H416Q395 364 386 383Q379 402 393 418L457 482Q467 491 480 491Q493 491 503 482L567 418Q581 402 574 383Q565 364 544 363H512V171H544Q565 170 574 151Q581 132 567 116L503 52Z'],++ 'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],++ 'fa-user': [448, 'M224 267Q259 267 288 250Q317 233 335 203Q352 173 352 139Q352 105 335 75Q317 45 288 28Q259 11 224 11Q189 11 160 28Q131 45 113 75Q96 105 96 139Q96 173 113 203Q131 233 160 250Q189 267 224 267ZM178 315Q103 317 52 367Q2 418 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q446 418 396 367Q345 317 270 315H178Z'],++ 'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],++ 'fa-video': [576, 'M0 139Q1 112 19 94Q37 76 64 75H320Q347 76 365 94Q383 112 384 139V395Q383 422 365 440Q347 458 320 459H64Q37 458 19 440Q1 422 0 395V139ZM559 111Q575 120 576 139V395Q575 414 559 423Q542 432 526 422L430 358L416 348V331V203V186L430 176L526 112Q542 103 559 111Z'],++ 'fa-wallet': [512, 'M64 43Q37 44 19 62Q1 80 0 107V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V203Q511 176 493 158Q475 140 448 139H80Q65 138 64 123Q65 108 80 107H448Q462 107 471 98Q480 89 480 75Q480 61 471 52Q462 43 448 43H64ZM416 283Q430 283 439 292Q448 301 448 315Q448 329 439 338Q430 347 416 347Q402 347 393 338Q384 329 384 315Q384 301 393 292Q402 283 416 283Z'],++ 'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],++ 'fa-arrow-up-right-from-square': [512, 'M320 11Q306 11 297 20Q288 29 288 43Q288 57 297 66Q306 75 320 75H403L201 276Q192 286 192 299Q192 312 201 322Q211 331 224 331Q237 331 247 322L448 120V203Q448 217 457 226Q466 235 480 235Q494 235 503 226Q512 217 512 203V43Q512 29 503 20Q494 11 480 11H320ZM80 43Q46 44 23 66Q1 89 0 123V443Q1 477 23 500Q46 522 80 523H400Q434 522 457 500Q479 477 480 443V331Q480 317 471 308Q462 299 448 299Q434 299 425 308Q416 317 416 331V443Q415 458 400 459H80Q65 458 64 443V123Q65 108 80 107H192Q206 107 215 98Q224 89 224 75Q224 61 215 52Q206 43 192 43H80Z'],++ 'fa-chevron-down': [512, 'M233 418Q243 427 256 427Q269 427 279 418L471 226Q480 216 480 203Q480 190 471 180Q461 171 448 171Q435 171 425 180L256 350L87 180Q77 171 64 171Q51 171 41 180Q32 190 32 203Q32 216 41 226L233 418Z'],++ 'fa-chevron-right': [320, 'M311 244Q320 254 320 267Q320 280 311 290L119 482Q109 491 96 491Q83 491 73 482Q64 472 64 459Q64 446 73 436L243 267L73 98Q64 88 64 75Q64 62 73 52Q83 43 96 43Q109 43 119 52L311 244Z'],++ 'fa-bars': [448, 'M0 107Q0 93 9 84Q18 75 32 75H416Q430 75 439 84Q448 93 448 107Q448 121 439 130Q430 139 416 139H32Q18 139 9 130Q0 121 0 107ZM0 267Q0 253 9 244Q18 235 32 235H416Q430 235 439 244Q448 253 448 267Q448 281 439 290Q430 299 416 299H32Q18 299 9 290Q0 281 0 267ZM448 427Q448 441 439 450Q430 459 416 459H32Q18 459 9 450Q0 441 0 427Q0 413 9 404Q18 395 32 395H416Q430 395 439 404Q448 413 448 427Z'],++ 'fa-xmark': [384, 'M343 162Q352 152 352 139Q352 126 343 116Q333 107 320 107Q307 107 297 116L192 222L87 116Q77 107 64 107Q51 107 41 116Q32 126 32 139Q32 152 41 162L147 267L41 372Q32 382 32 395Q32 408 41 418Q51 427 64 427Q77 427 87 418L192 312L297 418Q307 427 320 427Q333 427 343 418Q352 408 352 395Q352 382 343 372L237 267L343 162Z'],++ 'fa-magnifying-glass': [512, 'M416 219Q415 289 376 342L503 468Q512 478 512 491Q512 504 503 514Q493 523 480 523Q467 523 457 514L331 387Q278 426 208 427Q120 425 61 366Q2 307 0 219Q2 131 61 72Q120 13 208 11Q296 13 355 72Q414 131 416 219ZM208 363Q247 363 280 344Q313 325 333 291Q352 257 352 219Q352 181 333 147Q313 113 280 94Q247 75 208 75Q169 75 136 94Q103 113 83 147Q64 181 64 219Q64 257 83 291Q103 325 136 344Q169 363 208 363Z'],++ 'fa-gear': [512, 'M496 178Q500 192 490 202L446 242Q448 254 448 267Q448 280 446 292L490 332Q500 342 496 356Q489 374 480 391L475 399Q465 415 453 430Q443 441 429 437L373 419Q353 435 329 444L317 502Q313 516 298 519Q278 523 256 523Q234 523 213 519Q199 516 195 502L183 444Q159 435 139 419L83 437Q69 441 59 430Q46 415 37 399L32 391Q23 374 16 357Q12 342 22 332L66 293Q64 280 64 267Q64 254 66 242L22 202Q12 192 16 178Q23 160 32 143L37 135Q46 119 59 104Q69 93 83 97L139 115Q159 99 183 89L195 32Q199 18 214 15Q234 11 256 11Q278 11 299 15Q313 18 317 32L329 89Q353 99 373 115L429 97Q443 93 453 104Q466 119 476 135L480 143Q489 160 496 178ZM256 347Q301 346 325 307Q347 267 325 227Q301 188 256 187Q211 188 187 227Q165 267 187 307Q211 346 256 347Z'],++ 'fa-circle-half-stroke': [512, 'M448 267Q446 185 392 131Q338 77 256 75V459Q338 457 392 403Q446 349 448 267ZM0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267Z'],++ 'fa-moon': [384, 'M224 43Q161 44 111 74Q60 103 30 154Q1 204 0 267Q1 330 31 380Q60 431 111 460Q161 490 224 491Q317 489 379 428Q387 419 382 409Q377 399 365 400Q351 403 335 403Q261 401 211 351Q162 302 160 227Q160 177 184 137Q209 97 249 74Q259 68 257 56Q254 46 242 44Q233 43 223 43Z'],++ 'fa-sun': [512, 'M362 12Q369 16 371 24L391 132L499 152Q507 154 511 161Q514 169 509 177L447 267L509 357Q514 365 511 373Q507 380 499 382L391 402L371 510Q369 518 362 522Q354 525 346 520L256 458L166 520Q158 525 151 522Q143 518 141 510L121 402L13 382Q5 380 1 373Q-2 365 3 357L65 267L3 177Q-2 169 1 162Q5 154 13 152L121 132L141 24Q143 16 151 12Q158 9 166 14L256 76L346 14Q354 9 362 12ZM160 267Q160 241 173 219Q186 197 208 184Q231 171 256 171Q281 171 304 184Q326 197 339 219Q352 241 352 267Q352 293 339 315Q326 337 304 350Q281 363 256 363Q231 363 208 350Q186 337 173 315Q160 293 160 267ZM384 267Q384 232 367 203Q350 174 320 156Q290 139 256 139Q222 139 192 156Q162 174 145 203Q128 232 128 267Q128 302 145 331Q162 360 192 378Q222 395 256 395Q290 395 320 378Q350 360 367 331Q384 302 384 267Z'],++ 'fa-file-pdf': [512, 'M0 75Q1 48 19 30Q37 12 64 11H224V139Q224 153 233 162Q242 171 256 171H384V315H176Q149 316 131 334Q113 352 112 379V523H64Q37 522 19 504Q1 486 0 459V75ZM384 139H256H384H256V11L384 139ZM176 363H208H176H208Q232 364 248 379Q263 395 264 419Q263 443 248 459Q232 474 208 475H192V507Q191 522 176 523Q161 522 160 507V459V379Q161 364 176 363ZM208 443Q230 441 232 419Q230 397 208 395H192V443H208ZM304 363H336H304H336Q356 364 370 377Q383 391 384 411V475Q383 495 370 509Q356 522 336 523H304Q289 522 288 507V379Q289 364 304 363ZM336 491Q351 490 352 475V411Q351 396 336 395H320V491H336ZM416 379Q417 364 432 363H480Q495 364 496 379Q495 394 480 395H448V427H480Q495 428 496 443Q495 458 480 459H448V507Q447 522 432 523Q417 522 416 507V443V379Z'],++ 'fa-arrow-down': [384, 'M169 482Q179 491 192 491Q205 491 215 482L375 322Q384 312 384 299Q384 286 375 276Q365 267 352 267Q339 267 329 276L224 382V75Q224 61 215 52Q206 43 192 43Q178 43 169 52Q160 61 160 75V382L55 276Q45 267 32 267Q19 267 9 276Q0 286 0 299Q0 312 9 322L169 482Z'],++ 'fa-arrow-right': [448, 'M439 290Q448 280 448 267Q448 254 439 244L279 84Q269 75 256 75Q243 75 233 84Q224 94 224 107Q224 120 233 130L339 235H32Q18 235 9 244Q0 253 0 267Q0 281 9 290Q18 299 32 299H339L233 404Q224 414 224 427Q224 440 233 450Q243 459 256 459Q269 459 279 450L439 290Z'],++ 'fa-clock': [512, 'M256 11Q326 12 384 45Q442 79 478 139Q512 200 512 267Q512 334 478 395Q442 455 384 489Q326 522 256 523Q186 522 128 489Q70 455 34 395Q0 334 0 267Q0 200 34 139Q70 79 128 45Q186 12 256 11ZM232 131V267V131V267Q232 280 243 287L339 351Q358 362 372 344Q383 325 365 311L280 254V131Q278 109 256 107Q234 109 232 131Z'],++ 'fa-envelope': [512, 'M48 75Q28 76 14 89Q1 103 0 123Q1 147 19 161L237 325Q256 337 275 325L493 161Q511 147 512 123Q511 103 498 89Q484 76 464 75H48ZM0 187V395V187V395Q1 422 19 440Q37 458 64 459H448Q475 458 493 440Q511 422 512 395V187L294 350Q277 363 256 363Q235 363 218 350L0 187Z'],++ 'fa-star': [576, 'M317 29Q308 12 288 11Q269 12 259 29L195 161L51 183Q32 186 26 204Q20 223 34 237L138 340L113 486Q111 505 126 517Q142 528 160 519L288 451L417 519Q434 528 450 517Q466 505 463 486L439 340L543 237Q556 223 551 204Q544 186 525 183L381 161L317 29Z'],++ 'fa-heart': [512, 'M48 311 228 480 48 311 228 480Q240 491 256 491Q272 491 284 480L464 311Q511 267 512 202V196Q511 142 478 104Q445 65 393 55Q358 50 325 60Q293 70 268 95L256 107L244 95Q219 70 187 60Q154 50 119 55Q67 65 34 104Q1 142 0 196V202Q1 267 48 311Z'],++ 'fa-play': [384, 'M73 50Q49 36 25 49Q1 63 0 91V443Q1 471 25 485Q49 498 73 484L361 308Q383 294 384 267Q383 241 361 226L73 50Z'],++ 'fa-circle-info': [512, 'M256 523Q326 522 384 489Q442 455 478 395Q512 334 512 267Q512 200 478 139Q442 79 384 45Q326 12 256 11Q186 12 128 45Q70 79 34 139Q0 200 0 267Q0 334 34 395Q70 455 128 489Q186 522 256 523ZM216 347H240H216H240V283H216Q194 281 192 259Q194 237 216 235H264Q286 237 288 259V347H296Q318 349 320 371Q318 393 296 395H216Q194 393 192 371Q194 349 216 347ZM256 139Q270 139 279 148Q288 157 288 171Q288 185 279 194Q270 203 256 203Q242 203 233 194Q224 185 224 171Q224 157 233 148Q242 139 256 139Z'],++ 'fa-triangle-exclamation': [512, 'M256 43Q279 44 291 63L507 431Q517 451 507 471Q495 490 472 491H40Q17 490 5 471Q-5 451 5 431L222 63Q234 44 256 43ZM256 171Q234 173 232 195V307Q234 329 256 331Q278 329 280 307V195Q278 173 256 171ZM288 395Q288 381 279 372Q270 363 256 363Q242 363 233 372Q224 381 224 395Q224 409 233 418Q242 427 256 427Q270 427 279 418Q288 409 288 395Z'],++ 'fa-check': [448, 'M439 116Q448 126 448 139Q448 152 439 162L183 418Q173 427 160 427Q147 427 137 418L9 290Q0 280 0 267Q0 254 9 244Q19 235 32 235Q45 235 55 244L160 350L393 116Q403 107 416 107Q429 107 439 116Z'],++ 'fa-globe': [512, 'M352 267Q352 300 349 331H163Q160 300 160 267Q160 234 163 203H349Q352 234 352 267ZM381 203H504H381H504Q512 234 512 267Q512 300 504 331H381Q384 300 384 267Q384 234 381 203ZM493 171H377H493H377Q361 73 321 19Q381 36 426 75Q470 115 493 171ZM344 171H168H344H168Q177 116 195 76Q211 41 228 25Q245 10 256 11Q267 10 284 25Q301 41 317 76Q335 116 344 171ZM135 171H19H135H19Q42 115 86 75Q131 36 191 19Q151 73 135 171ZM8 203H131H8H131Q128 234 128 267Q128 300 131 331H8Q0 300 0 267Q0 234 8 203ZM195 458Q177 418 168 363H344Q335 418 317 458Q301 493 284 509Q267 524 256 523Q245 524 228 509Q211 493 195 458ZM135 363Q151 461 191 515Q131 498 86 459Q42 419 19 363H135ZM493 363Q470 419 426 459Q381 498 322 515Q361 461 377 363H493Z'],++ 'fa-code': [640, 'M393 12Q380 9 368 15Q357 21 353 34L225 482Q222 495 228 507Q234 518 247 522Q260 525 272 519Q283 513 287 500L415 52Q418 39 412 27Q406 16 393 12ZM473 132Q464 142 464 155Q464 168 473 178L563 267L473 356Q464 366 464 379Q464 392 473 402Q483 411 496 411Q509 411 519 402L631 290Q640 280 640 267Q640 254 631 244L519 132Q509 123 496 123Q483 123 473 132ZM167 132Q157 123 144 123Q131 123 121 132L9 244Q0 254 0 267Q0 280 9 290L121 402Q131 411 144 411Q157 411 167 402Q176 392 176 379Q176 366 167 356L77 267L167 178Q176 168 176 155Q176 142 167 132Z'],++ 'fa-robot': [640, 'M320 11Q334 11 343 20Q352 29 352 43V107H472Q503 108 523 128Q543 148 544 179V451Q543 482 523 502Q503 522 472 523H168Q137 522 117 502Q97 482 96 451V179Q97 148 117 128Q137 108 168 107H288V43Q288 29 297 20Q306 11 320 11ZM208 395Q193 396 192 411Q193 426 208 427H240Q255 426 256 411Q255 396 240 395H208ZM304 395Q289 396 288 411Q289 426 304 427H336Q351 426 352 411Q351 396 336 395H304ZM400 395Q385 396 384 411Q385 426 400 427H432Q447 426 448 411Q447 396 432 395H400ZM264 267Q263 244 244 232Q224 222 204 232Q185 244 184 267Q185 290 204 302Q224 312 244 302Q263 290 264 267ZM416 307Q439 306 451 287Q461 267 451 247Q439 228 416 227Q393 228 381 247Q371 267 381 287Q393 306 416 307ZM48 235H64H48H64V427H48Q28 426 14 413Q1 399 0 379V283Q1 263 14 249Q28 236 48 235ZM592 235Q612 236 626 249Q639 263 640 283V379Q639 399 626 413Q612 426 592 427H576V235H592Z'],++ 'fa-photo-film': [640, 'M256 11H576H256H576Q603 12 621 30Q639 48 640 75V299Q639 326 621 344Q603 362 576 363H256Q229 362 211 344Q193 326 192 299V75Q193 48 211 30Q229 12 256 11ZM476 118Q469 107 456 107Q443 107 436 118L380 202L363 180Q355 171 344 171Q333 171 325 180L261 260Q252 272 258 285Q265 298 280 299H360H552Q566 299 573 286Q579 274 572 262L476 118ZM336 107Q336 93 327 84Q318 75 304 75Q290 75 281 84Q272 93 272 107Q272 121 281 130Q290 139 304 139Q318 139 327 130Q336 121 336 107ZM64 139H160H64H160V395V427Q160 441 169 450Q178 459 192 459H320Q334 459 343 450Q352 441 352 427V395H512V459Q511 486 493 504Q475 522 448 523H64Q37 522 19 504Q1 486 0 459V203Q1 176 19 158Q37 140 64 139ZM72 203Q57 204 56 219V235Q57 250 72 251H88Q103 250 104 235V219Q103 204 88 203H72ZM72 307Q57 308 56 323V339Q57 354 72 355H88Q103 354 104 339V323Q103 308 88 307H72ZM72 411Q57 412 56 427V443Q57 458 72 459H88Q103 458 104 443V427Q103 412 88 411H72ZM408 427V443V427V443Q409 458 424 459H440Q455 458 456 443V427Q455 412 440 411H424Q409 412 408 427Z'],++ 'fa-tower-cell': [576, 'M63 13Q50 9 38 14Q26 19 21 31Q0 81 0 139Q0 197 21 247Q26 259 38 264Q50 269 63 265Q75 259 80 248Q85 236 80 223Q64 184 64 139Q64 94 80 55Q85 42 80 31Q75 19 63 13ZM513 13Q501 19 496 31Q491 42 496 55Q512 94 512 139Q512 184 496 223Q491 236 496 247Q501 259 513 265Q526 270 538 265Q550 259 555 247Q576 197 576 139Q576 81 555 31Q550 19 538 14Q526 9 513 13ZM340 176Q352 160 352 139Q351 112 333 94Q315 76 288 75Q261 76 243 94Q225 112 224 139Q224 160 236 176L99 478Q94 490 98 502Q103 514 115 520Q127 526 139 521Q151 517 157 504L178 459H398L419 504Q425 516 437 521Q449 525 461 520Q474 514 478 502Q483 490 477 478L340 176ZM369 395H207H369H207L221 363H355L369 395ZM288 216 326 299 288 216 326 299H250L288 216ZM163 85Q170 64 151 53Q130 46 119 65Q104 100 104 139Q104 178 119 213Q130 232 151 225Q170 214 163 193Q152 168 152 139Q152 110 163 85ZM457 65Q446 46 425 53Q406 64 413 85Q424 110 424 139Q424 168 413 193Q406 214 425 225Q446 232 457 213Q472 178 472 139Q472 100 457 65Z'],++ 'fa-diagram-project': [576, 'M0 91Q1 71 14 57Q28 44 48 43H144Q164 44 178 57Q191 71 192 91V107H384V91Q385 71 398 57Q412 44 432 43H528Q548 44 562 57Q575 71 576 91V187Q575 207 562 221Q548 234 528 235H432Q412 234 398 221Q385 207 384 187V171H192V187Q192 190 192 192L272 299H368Q388 300 402 313Q415 327 416 347V443Q415 463 402 477Q388 490 368 491H272Q252 490 238 477Q225 463 224 443V347Q224 344 224 342L144 235H48Q28 234 14 221Q1 207 0 187V91Z'],++ 'fa-toolbox': [512, 'M176 99V139V99V139H336V99Q335 92 328 91H184Q177 92 176 99ZM128 139V99V139V99Q129 75 144 59Q160 44 184 43H328Q352 44 368 59Q383 75 384 99V139H412Q432 139 446 153L498 205Q512 219 512 239V315H384V299Q384 285 375 276Q366 267 352 267Q338 267 329 276Q320 285 320 299V315H192V299Q192 285 183 276Q174 267 160 267Q146 267 137 276Q128 285 128 299V315H0V239Q0 219 14 205L66 153Q80 139 100 139H128ZM0 427V347V427V347H128V363Q128 377 137 386Q146 395 160 395Q174 395 183 386Q192 377 192 363V347H320V363Q320 377 329 386Q338 395 352 395Q366 395 375 386Q384 377 384 363V347H512V427Q511 454 493 472Q475 490 448 491H64Q37 490 19 472Q1 454 0 427Z'],++ 'fa-comment-dots': [512, 'M256 459Q328 458 385 431Q443 403 477 356Q511 309 512 251Q511 193 477 146Q443 99 385 71Q328 44 256 43Q184 44 127 71Q69 99 35 146Q1 193 0 251Q1 320 48 372Q43 409 26 435Q18 449 11 456Q8 460 6 462Q6 463 5 463Q5 463 5 464Q-2 471 1 481Q6 491 16 491Q60 489 98 472Q133 456 152 441Q200 459 256 459ZM128 219Q142 219 151 228Q160 237 160 251Q160 265 151 274Q142 283 128 283Q114 283 105 274Q96 265 96 251Q96 237 105 228Q114 219 128 219ZM256 219Q270 219 279 228Q288 237 288 251Q288 265 279 274Q270 283 256 283Q242 283 233 274Q224 265 224 251Q224 237 233 228Q242 219 256 219ZM352 251Q352 237 361 228Q370 219 384 219Q398 219 407 228Q416 237 416 251Q416 265 407 274Q398 283 384 283Q370 283 361 274Q352 265 352 251Z'],++ 'fa-money-bill-wave': [576, 'M0 124V433V124V433Q1 463 27 475Q92 497 158 489Q223 481 288 463Q348 446 408 437Q468 429 527 444Q545 448 560 438Q575 429 576 410V101Q575 71 549 59Q484 37 419 45Q353 53 288 71Q228 88 168 96Q108 105 49 90Q30 86 16 95Q1 105 0 124ZM288 363Q254 362 231 335Q209 308 208 267Q209 226 231 199Q254 172 288 171Q322 172 345 199Q367 226 368 267Q367 308 345 335Q322 362 288 363ZM64 363Q91 364 109 382Q127 400 128 427H64V363ZM128 155Q127 182 109 200Q91 218 64 219V155H128ZM512 315V379V315V379H448Q449 352 467 334Q485 316 512 315ZM448 107H512H448H512V171Q485 170 467 152Q449 134 448 107Z'],++ 'fa-crown': [576, 'M309 117Q327 105 328 83Q328 66 316 55Q305 43 288 43Q271 43 260 55Q248 66 248 83Q249 106 267 117L210 232Q202 245 188 249Q174 252 161 242L72 171Q80 161 80 147Q80 130 68 119Q57 107 40 107Q23 107 12 119Q0 130 0 147Q0 164 12 175Q23 187 40 187Q40 187 40 187Q41 187 41 187L86 438Q91 462 108 476Q126 491 149 491H427Q450 491 468 476Q485 462 490 438L535 187Q535 187 536 187Q536 187 536 187Q553 187 564 175Q576 164 576 147Q576 130 564 119Q553 107 536 107Q519 107 508 119Q496 130 496 147Q496 161 504 171L415 242Q402 252 388 249Q374 245 366 232L309 117Z'],++ 'fa-server': [512, 'M64 43Q37 44 19 62Q1 80 0 107V171Q1 198 19 216Q37 234 64 235H448Q475 234 493 216Q511 198 512 171V107Q511 80 493 62Q475 44 448 43H64ZM344 115Q366 117 368 139Q366 161 344 163Q322 161 320 139Q322 117 344 115ZM392 139Q394 117 416 115Q438 117 440 139Q438 161 416 163Q394 161 392 139ZM64 299Q37 300 19 318Q1 336 0 363V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V363Q511 336 493 318Q475 300 448 299H64ZM344 371Q366 373 368 395Q366 417 344 419Q322 417 320 395Q322 373 344 371ZM400 395Q402 373 424 371Q446 373 448 395Q446 417 424 419Q402 417 400 395Z'],++ 'fa-newspaper': [512, 'M96 107Q97 80 115 62Q133 44 160 43H448Q475 44 493 62Q511 80 512 107V427Q511 454 493 472Q475 490 448 491H80Q46 490 23 468Q1 445 0 411V139Q0 125 9 116Q18 107 32 107Q46 107 55 116Q64 125 64 139V411Q65 426 80 427Q95 426 96 411V107ZM160 131V211V131V211Q162 233 184 235H296Q318 233 320 211V131Q318 109 296 107H184Q162 109 160 131ZM368 123Q369 138 384 139H432Q447 138 448 123Q447 108 432 107H384Q369 108 368 123ZM368 219Q369 234 384 235H432Q447 234 448 219Q447 204 432 203H384Q369 204 368 219ZM160 315Q161 330 176 331H432Q447 330 448 315Q447 300 432 299H176Q161 300 160 315ZM160 411Q161 426 176 427H432Q447 426 448 411Q447 396 432 395H176Q161 396 160 411Z'],++ 'fa-tag': [448, 'M0 91V241V91V241Q0 267 19 286L195 462Q214 481 240 481Q265 481 285 462L419 328Q437 309 437 283Q437 258 419 238L243 62Q224 43 197 43H48Q28 44 14 57Q1 71 0 91ZM112 123Q126 123 135 132Q144 141 144 155Q144 169 135 178Q126 187 112 187Q98 187 89 178Q80 169 80 155Q80 141 89 132Q98 123 112 123Z'],++ 'fa-ticket': [576, 'M64 75Q37 76 19 94Q1 112 0 139V203Q2 217 16 222Q46 233 48 267Q46 301 16 312Q2 317 0 331V395Q1 422 19 440Q37 458 64 459H512Q539 458 557 440Q575 422 576 395V331Q574 317 560 312Q530 301 528 267Q530 233 560 222Q574 217 576 203V139Q575 112 557 94Q539 76 512 75H64ZM128 187V347V187V347Q129 362 144 363H432Q447 362 448 347V187Q447 172 432 171H144Q129 172 128 187ZM96 171Q96 157 105 148Q114 139 128 139H448Q462 139 471 148Q480 157 480 171V363Q480 377 471 386Q462 395 448 395H128Q114 395 105 386Q96 377 96 363V171Z'],++ 'fa-chart-line': [512, 'M64 75Q64 61 55 52Q46 43 32 43Q18 43 9 52Q0 61 0 75V411Q1 445 23 468Q46 490 80 491H480Q494 491 503 482Q512 473 512 459Q512 445 503 436Q494 427 480 427H80Q65 426 64 411V75ZM471 162Q480 152 480 139Q480 126 471 116Q461 107 448 107Q435 107 425 116L320 222L263 164Q253 155 240 155Q227 155 217 164L105 276Q96 286 96 299Q96 312 105 322Q115 331 128 331Q141 331 151 322L240 232L297 290Q307 299 320 299Q333 299 343 290L471 162Z'],++ 'fa-pen-nib': [512, 'M368 29 313 85 368 29 313 85 438 210 494 155Q510 137 510 115Q510 93 494 75L448 29Q430 13 408 13Q386 13 368 29ZM288 106 279 108 288 106 279 108 135 152Q104 162 92 193L4 457Q-2 475 11 489L165 336Q160 326 160 315Q161 295 174 281Q188 268 208 267Q228 268 242 281Q255 295 256 315Q255 335 242 349Q228 362 208 363Q197 363 187 358L34 512Q48 525 66 519L330 431Q361 419 371 388L415 244L417 235L288 106Z'],++ 'fa-key': [512, 'M336 363Q411 361 460 311Q510 262 512 187Q510 112 460 63Q411 13 336 11Q261 13 212 63Q162 112 160 187Q160 215 168 241L7 402Q0 409 0 419V499Q2 521 24 523H104Q126 521 128 499V459H168Q190 457 192 435V395H232Q242 395 249 388L282 355Q308 363 336 363ZM376 107Q399 108 411 127Q421 147 411 167Q399 186 376 187Q353 186 341 167Q331 147 341 127Q353 108 376 107Z'],++ 'fa-plug': [384, 'M96 11Q82 11 73 20Q64 29 64 43V139H128V43Q128 29 119 20Q110 11 96 11ZM288 11Q274 11 265 20Q256 29 256 43V139H320V43Q320 29 311 20Q302 11 288 11ZM32 171Q18 171 9 180Q0 189 0 203Q0 217 9 226Q18 235 32 235V267Q33 326 69 369Q104 412 160 424V491Q160 505 169 514Q178 523 192 523Q206 523 215 514Q224 505 224 491V424Q280 412 315 369Q351 326 352 267V235Q366 235 375 226Q384 217 384 203Q384 189 375 180Q366 171 352 171H32Z'],++ 'fa-bolt': [448, 'M349 56Q358 33 339 17Q318 4 299 19L43 243Q27 258 34 278Q43 298 64 299H176L99 478Q90 501 109 517Q130 530 149 515L405 291Q421 276 414 256Q406 236 384 235H273L349 56Z'],++ 'fa-satellite-dish': [512, 'M192 43Q192 29 201 20Q210 11 224 11Q304 12 369 50Q434 89 473 154Q511 219 512 299Q512 313 503 322Q494 331 480 331Q466 331 457 322Q448 313 448 299Q447 236 417 186Q388 135 337 106Q287 76 224 75Q210 75 201 66Q192 57 192 43ZM61 232 165 336 61 232 165 336 193 307Q192 303 192 299Q192 285 201 276Q210 267 224 267Q238 267 247 276Q256 285 256 299Q256 313 247 322Q238 331 224 331Q220 331 216 330L187 358L291 462Q302 474 300 488Q298 502 284 509Q249 523 208 523Q120 521 61 462Q2 403 0 315Q0 274 14 239Q21 225 35 223Q49 221 61 232ZM224 107Q306 109 360 163Q414 217 416 299Q416 313 407 322Q398 331 384 331Q370 331 361 322Q352 313 352 299Q351 245 315 208Q278 172 224 171Q210 171 201 162Q192 153 192 139Q192 125 201 116Q210 107 224 107Z'],++ 'fa-brain': [512, 'M184 11Q208 12 224 27Q239 43 240 67V467Q239 491 224 507Q208 522 184 523Q162 523 146 509Q131 495 128 473Q120 475 112 475Q85 474 67 456Q49 438 48 411Q48 400 52 390Q29 381 14 361Q0 341 0 315Q0 291 13 272Q25 253 46 243Q32 226 32 203Q32 180 46 163Q60 146 82 140Q80 132 80 123Q80 100 94 83Q107 67 128 61Q131 39 146 25Q162 11 184 11ZM328 11Q350 11 366 25Q381 39 384 61Q405 67 418 83Q432 100 432 123Q432 132 430 140Q452 146 466 163Q480 180 480 203Q480 226 466 243Q487 253 499 272Q512 291 512 315Q512 341 498 361Q483 381 460 390Q464 400 464 411Q463 438 445 456Q427 474 400 475Q392 475 384 473Q381 495 366 509Q350 523 328 523Q304 522 288 507Q273 491 272 467V67Q273 43 288 27Q304 12 328 11Z'],++ 'fa-microchip': [512, 'M176 35Q174 13 152 11Q130 13 128 35V75Q101 76 83 94Q65 112 64 139H24Q2 141 0 163Q2 185 24 187H64V243H24Q2 245 0 267Q2 289 24 291H64V347H24Q2 349 0 371Q2 393 24 395H64Q65 422 83 440Q101 458 128 459V499Q130 521 152 523Q174 521 176 499V459H232V499Q234 521 256 523Q278 521 280 499V459H336V499Q338 521 360 523Q382 521 384 499V459Q411 458 429 440Q447 422 448 395H488Q510 393 512 371Q510 349 488 347H448V291H488Q510 289 512 267Q510 245 488 243H448V187H488Q510 185 512 163Q510 141 488 139H448Q447 112 429 94Q411 76 384 75V35Q382 13 360 11Q338 13 336 35V75H280V35Q278 13 256 11Q234 13 232 35V75H176V35ZM160 139H352H160H352Q366 139 375 148Q384 157 384 171V363Q384 377 375 386Q366 395 352 395H160Q146 395 137 386Q128 377 128 363V171Q128 157 137 148Q146 139 160 139ZM352 171H160H352H160V363H352V171Z'],++ 'fa-circle-nodes': [512, 'M418 169Q445 162 462 141Q479 120 480 91Q479 57 457 34Q434 12 400 11Q367 12 344 34Q322 55 320 88L136 162Q114 140 80 139Q46 140 23 162Q1 185 0 219Q1 253 23 276Q46 298 80 299Q98 299 114 291L260 419Q256 430 256 443Q257 477 279 500Q302 522 336 523Q370 522 393 500Q415 477 416 443Q414 400 381 377L418 169ZM156 243Q160 233 160 222L344 148Q349 153 355 158L318 365Q309 367 302 371L156 243Z'],++ 'fa-download': [512, 'M288 43Q288 29 279 20Q270 11 256 11Q242 11 233 20Q224 29 224 43V286L151 212Q141 203 128 203Q115 203 105 212Q96 222 96 235Q96 248 105 258L233 386Q243 395 256 395Q269 395 279 386L407 258Q416 248 416 235Q416 222 407 212Q397 203 384 203Q371 203 361 212L288 286V43ZM64 363Q37 364 19 382Q1 400 0 427V459Q1 486 19 504Q37 522 64 523H448Q475 522 493 504Q511 486 512 459V427Q511 400 493 382Q475 364 448 363H347L301 408Q281 427 256 427Q230 427 211 408L166 363H64ZM432 419Q454 421 456 443Q454 465 432 467Q410 465 408 443Q410 421 432 419Z'],++ 'fa-upload': [512, 'M288 120V363V120V363Q288 377 279 386Q270 395 256 395Q242 395 233 386Q224 377 224 363V120L151 194Q141 203 128 203Q115 203 105 194Q96 184 96 171Q96 158 105 148L233 20Q243 11 256 11Q269 11 279 20L407 148Q416 158 416 171Q416 184 407 194Q397 203 384 203Q371 203 361 194L288 120ZM64 363H192H64H192Q193 390 211 408Q229 426 256 427Q283 426 301 408Q319 390 320 363H448Q475 364 493 382Q511 400 512 427V459Q511 486 493 504Q475 522 448 523H64Q37 522 19 504Q1 486 0 459V427Q1 400 19 382Q37 364 64 363ZM432 467Q454 465 456 443Q454 421 432 419Q410 421 408 443Q410 465 432 467Z'],++ 'fa-image': [512, 'M0 107Q1 80 19 62Q37 44 64 43H448Q475 44 493 62Q511 80 512 107V427Q511 454 493 472Q475 490 448 491H64Q37 490 19 472Q1 454 0 427V107ZM324 214Q316 203 304 203Q291 203 284 214L197 341L171 308Q163 299 152 299Q141 299 133 308L69 388Q61 400 66 413Q73 426 88 427H184H424Q438 427 445 414Q451 401 444 390L324 214ZM112 203Q139 202 154 179Q166 155 154 131Q139 108 112 107Q85 108 70 131Q58 155 70 179Q85 202 112 203Z'],++ 'fa-music': [512, 'M499 17Q512 27 512 43V115V379Q511 413 484 436Q457 458 416 459Q375 458 348 436Q321 413 320 379Q321 345 348 322Q375 300 416 299Q433 299 448 304V158L192 235V443Q191 477 164 500Q137 522 96 523Q55 522 28 500Q1 477 0 443Q1 409 28 386Q55 364 96 363Q113 363 128 368V211V139Q129 116 151 108L471 12Q486 8 499 17Z'],++ 'fa-file-lines': [384, 'M64 11Q37 12 19 30Q1 48 0 75V459Q1 486 19 504Q37 522 64 523H320Q347 522 365 504Q383 486 384 459V171H256Q242 171 233 162Q224 153 224 139V11H64ZM256 11V139V11V139H384L256 11ZM112 267H272H112H272Q287 268 288 283Q287 298 272 299H112Q97 298 96 283Q97 268 112 267ZM112 331H272H112H272Q287 332 288 347Q287 362 272 363H112Q97 362 96 347Q97 332 112 331ZM112 395H272H112H272Q287 396 288 411Q287 426 272 427H112Q97 426 96 411Q97 396 112 395Z'],++ 'fa-font': [448, 'M254 64Q245 44 224 43Q203 44 194 64L58 427H32Q18 427 9 436Q0 445 0 459Q0 473 9 482Q18 491 32 491H128Q142 491 151 482Q160 473 160 459Q160 445 151 436Q142 427 128 427H126L144 379H304L322 427H320Q306 427 297 436Q288 445 288 459Q288 473 297 482Q306 491 320 491H416Q430 491 439 482Q448 473 448 459Q448 445 439 436Q430 427 416 427H390L254 64ZM280 315H168H280H168L224 166L280 315Z'],++ 'fa-location-dot': [384, 'M216 510Q243 477 282 421Q321 366 352 307Q382 248 384 203Q382 121 328 67Q274 13 192 11Q110 13 56 67Q2 121 0 203Q2 248 32 307Q63 366 102 421Q141 477 168 510Q178 522 192 522Q206 522 216 510ZM192 139Q228 140 247 171Q265 203 247 235Q228 266 192 267Q156 266 137 235Q119 203 137 171Q156 140 192 139Z'],++ 'fa-lock': [448, 'M144 155V203V155V203H304V155Q303 121 281 98Q258 76 224 75Q190 76 167 98Q145 121 144 155ZM80 203V155V203V155Q82 94 122 53Q163 13 224 11Q285 13 326 53Q366 94 368 155V203H384Q411 204 429 222Q447 240 448 267V459Q447 486 429 504Q411 522 384 523H64Q37 522 19 504Q1 486 0 459V267Q1 240 19 222Q37 204 64 203H80Z'],++ 'fa-wave-square': [640, 'M128 75Q128 61 137 52Q146 43 160 43H320Q334 43 343 52Q352 61 352 75V427H448V267Q448 253 457 244Q466 235 480 235H608Q622 235 631 244Q640 253 640 267Q640 281 631 290Q622 299 608 299H512V459Q512 473 503 482Q494 491 480 491H320Q306 491 297 482Q288 473 288 459V107H192V267Q192 281 183 290Q174 299 160 299H32Q18 299 9 290Q0 281 0 267Q0 253 9 244Q18 235 32 235H128V75Z'],++ 'fa-map': [576, 'M384 487 192 432 384 487 192 432V47L384 102V487ZM416 486V99V486V99L543 49Q556 44 565 51Q575 58 576 71V406Q575 421 561 428L416 486ZM15 106 160 48 15 106 160 48V435L33 486Q20 490 11 483Q1 476 0 463V128Q1 113 15 106Z'],++ 'fa-utensils': [448, 'M416 11Q406 10 376 25Q345 39 318 77Q290 116 288 187V299Q289 326 307 344Q325 362 352 363H384V491Q384 505 393 514Q402 523 416 523Q430 523 439 514Q448 505 448 491V363V251V43Q448 29 439 20Q430 11 416 11ZM64 27Q63 14 50 11Q36 11 32 24L2 160Q0 169 0 179Q1 214 23 238Q46 263 80 267V491Q80 505 89 514Q98 523 112 523Q126 523 135 514Q144 505 144 491V267Q178 263 201 238Q223 214 224 179Q224 169 222 160L192 24Q188 11 174 11Q161 14 160 27V161Q159 170 150 171Q142 170 140 162L128 26Q126 12 112 11Q98 12 96 26L84 162Q82 170 74 171Q65 170 64 161V27ZM112 179V178V179Z'],++ 'fa-user-shield': [640, 'M224 267Q259 267 288 250Q317 233 335 203Q352 173 352 139Q352 105 335 75Q317 45 288 28Q259 11 224 11Q189 11 160 28Q131 45 113 75Q96 105 96 139Q96 173 113 203Q131 233 160 250Q189 267 224 267ZM178 315Q103 317 52 367Q2 418 0 493Q0 506 9 514Q17 523 30 523H418Q421 523 424 523Q367 479 345 424Q322 368 320 322Q296 315 270 315H178ZM487 237 367 285 487 237 367 285Q353 291 352 307Q351 340 363 381Q374 422 403 460Q433 498 487 521Q496 525 505 521Q559 498 589 460Q618 422 629 381Q641 340 640 307Q639 291 625 285L505 237Q496 233 487 237ZM591 323Q589 363 568 404Q546 446 496 473V285L591 323Z'],++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = NAV_ICONS;++ else root.OpenVibeNavIcons = NAV_ICONS;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,1628 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Universal Navbar++// Consistent top bar across all services with logo, navigation,++// notification bell, account switcher, and theme-aware styling.++// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })++// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin++// endpoint returning { user } — asked when no usable ov_token exists, including++// when a stored token turned out stale), logoutUrl (where Sign out goes, e.g. the++// site's '/auth/logout?next={path}', so a server-side session ends too; {url} is++// the full return URL, {path} its local path — loginUrl takes both too),++// 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, logoutUrl: 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() {++ if (document.getElementById('openvibe-navbar-styles')) return;++ const s = document.createElement('style');++ s.id = 'openvibe-navbar-styles';++ s.textContent = `++ .openvibe-navbar {++ position: sticky; top: 0; z-index: 10000;++ height: 52px; display: flex; align-items: center; padding: 0 16px; gap: 8px;++ background: var(--bg-secondary, #252530);++ border-bottom: 1px solid var(--border, #333340);++ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;++ color: var(--text-primary, #e0e0e0);++ }++ .openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }++ /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host++ page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */++ .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }++ .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }++ .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }++ .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }++ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }++ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }++ .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }++ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }++ .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }++ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }++ .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }++ .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }++ .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }++ .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }++ @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }++ .openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; height: 1em; font-size: 1em; vertical-align: -.125em; overflow: visible; }++ .openvibe-navbar-links { min-width: 0; overflow-x: auto; scrollbar-width: none; }++ .openvibe-navbar-links::-webkit-scrollbar { display: none; }++ .openvibe-navbar-links a.ovnav-link { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex: none; }++ .openvibe-navbar .ovnav-link[hidden], .openvibe-navbar .ovnav-dd[hidden], .openvibe-navbar-dropdown [hidden] { display: none !important; }++ .openvibe-navbar .ovnav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 0 rgba(239,68,68,.6); animation: ovnavPulse 1.8s infinite; }++ .openvibe-navbar .ovnav-dot[hidden] { display: none; }++ @keyframes ovnavPulse { 70% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); } }++ .openvibe-navbar .ovnav-caret { font-size: 10px; opacity: .6; }++ .openvibe-navbar .ovnav-dd { position: relative; display: inline-flex; flex: none; }++ .openvibe-navbar .ovnav-dd-menu { display: none; position: absolute; top: 100%; left: 0; min-width: 180px; padding: 5px; border-radius: 12px; z-index: 1002; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.45); flex-direction: column; gap: 2px; }++ .openvibe-navbar .ovnav-dd:hover .ovnav-dd-menu, .openvibe-navbar .ovnav-dd.open .ovnav-dd-menu, .openvibe-navbar .ovnav-dd:focus-within .ovnav-dd-menu { display: flex; }++ .openvibe-navbar .ovnav-chip { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px; border-radius: 999px; font: 700 13px/1 inherit; color: var(--chip-c, var(--text-primary, #e6edf7)); background: color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 38%, transparent); font-variant-numeric: tabular-nums; }++ .ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }++ .openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }++ .openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }++ .openvibe-navbar-dropdown .ovnav-chip--menu { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font: 700 12px/1.2 inherit; color: var(--chip-c, #fbbf24); background: color-mix(in srgb, var(--chip-c, #fbbf24) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, #fbbf24) 34%, transparent); }++ .openvibe-navbar .ovnav-burger { all: unset; box-sizing: border-box; cursor: pointer; display: none; width: 38px; height: 38px; border-radius: 11px; flex-direction: column; align-items: center; justify-content: center; gap: 4px; flex: none; }++ .openvibe-navbar .ovnav-burger span { display: block; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transition: transform .2s, opacity .2s; }++ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }++ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }++ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }++ .openvibe-navbar .ovnav-burger:hover, .openvibe-navbar .ovnav-burger[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .ovnav-drawer { display: none; position: fixed; top: calc(var(--ovnav-h, 56px) + 6px); right: 8px; width: min(300px, calc(100vw - 16px)); padding: 7px; border-radius: 16px; z-index: 1001; flex-direction: column; gap: 2px;++ background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 22px 48px rgba(0,0,0,.45);++ max-height: calc(100vh - var(--ovnav-h, 56px) - 16px); max-height: calc(100dvh - var(--ovnav-h, 56px) - 16px - env(safe-area-inset-bottom, 0px)); overflow-y: auto; overscroll-behavior: contain; }++ .ovnav-drawer.open { display: flex; animation: openvibe-slide-down .2s ease; }++ .ovnav-drawer a { display: flex; align-items: center; gap: 10px; padding: 11px 12px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14.5px; font-weight: 600; }++ .ovnav-drawer a.ovnav-sublink { padding-left: 34px; font-weight: 500; color: var(--text-secondary, #a8b3c4); }++ .ovnav-drawer a:hover, .ovnav-drawer a.active { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .ovnav-drawer a[hidden] { display: none; }++ .ovnav-drawer .ud-label { padding: 10px 12px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }++ .ovnav-drawer-net:empty { display: none; } .ovnav-drawer-net { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin-top: 4px; }++ .ovnav-drawer-net a.ovnav-net { display: flex !important; }++ @media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }++ @media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }++ .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); 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-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }++ .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }++ .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }++ .ovnav-launcher .ovl-display { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 10px 4px 2px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #7d8aa0); }++ .ovnav-launcher .ovl-display[hidden] { display: none; }++ .ovnav-launcher .ovl-dl { font-weight: 700; letter-spacing: .6px; text-transform: uppercase; font-size: 11px; margin-right: 2px; }++ .ovnav-launcher .ovl-seg { display: inline-flex; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 9px; overflow: hidden; }++ .ovnav-launcher .ovl-seg button { all: unset; cursor: pointer; padding: 5px 9px; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }++ .ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }++ .ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }++ .ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }++ .ovnav-launcher .ovl-addr a { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }++ .ovnav-launcher .ovl-addr a.is-here { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); }++ .ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }++ .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, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }++ .openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }++ /* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */++ .openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; }++ .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; }++ .openvibe-navbar-brand .b-dot { color: var(--accent, #3b82f6); opacity: .75; margin: 0 1px; font-weight: 800; }++ .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; }++ .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #60a5fa); }++ .openvibe-navbar-brand:hover .b-dot { opacity: 1; }++ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #60a5fa); background: var(--accent-glow, rgba(59,130,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; }++ /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core,++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld,++ .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, #60a5fa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }++ .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }++ .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #60a5fa); flex: none; }++ .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }++ .openvibe-navbar-dropdown { width: min(300px, calc(100vw - 16px)); }++ .openvibe-navbar-dropdown-menu .ud-label { padding: 8px 10px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }++ .openvibe-navbar-dropdown-menu .ud-val { margin-left: auto; font-weight: 600; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }++ .openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }++ .openvibe-navbar-dropdown-header { background: linear-gradient(180deg, color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent), transparent); }++ .openvibe-navbar-dropdown-header img { border-radius: 50%; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }++ .openvibe-navbar-dropdown-header .ud-wallet a { display: inline-flex; align-items: center; gap: 5px; margin-top: 5px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; text-decoration: none; color: #fbbf24; background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); }++ .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 {++ padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;++ color: var(--text-secondary, #b0b0b8); text-decoration: none;++ transition: all .15s;++ }++ .openvibe-navbar-links a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-links a.active { background: var(--bg-tertiary, #2a2a38); color: var(--accent-light, #60a5fa); }++++ .openvibe-navbar-spacer { flex: 1; }++++ .openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }++++ .openvibe-navbar-avatar {++ width: 32px; height: 32px; border-radius: 50%; cursor: pointer;++ border: 2px solid var(--border, #333340); transition: border-color .2s;++ object-fit: cover;++ }++ .openvibe-navbar-avatar:hover { border-color: var(--accent, #3b82f6); }++++ .openvibe-navbar-login {++ padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;++ /* --accent-strong / --on-accent-strong (builtin-themes.js deriveTokens) read at 4.5:1 or better in++ every theme (WCAG AA); the accent itself did not (white on #3b82f6 is 3.67:1). */++ background: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); border: none; cursor: pointer;++ transition: filter .15s; text-decoration: none; display: inline-flex; align-items: center;++ }++ .openvibe-navbar-login:hover { filter: brightness(1.12); }++++ .openvibe-navbar-dropdown {++ position: absolute; top: 48px; right: 8px;++ width: 260px; background: var(--bg-card, #22222c);++ border: 1px solid var(--border, #333340); border-radius: 10px;++ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));++ display: none; flex-direction: column; overflow: hidden;++ animation: openvibe-slide-down .2s ease;++ }++ .openvibe-navbar-dropdown.open { display: flex; }++ /* Panels never leave the screen: dvh is the VISIBLE height on phones (100vh reaches behind the address++ bar), the panel scrolls inside itself, and it is never wider than the viewport. */++ .openvibe-navbar-dropdown, .ovnav-launcher {++ max-width: calc(100vw - 16px);++ max-height: calc(100vh - 64px); max-height: calc(100dvh - 64px - env(safe-area-inset-bottom, 0px));++ overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;++ }++ @keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }++++ .openvibe-navbar-dropdown-header {++ padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);++ display: flex; align-items: center; gap: 10px;++ }++ .openvibe-navbar-dropdown-header img { width: 36px; height: 36px; border-radius: 50%; }++ .openvibe-navbar-dropdown-header .info { line-height: 1.3; }++ .openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; }++ .openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); }++ .openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #60a5fa); }++++ .openvibe-navbar-dropdown-accounts {++ padding: 6px 8px; border-bottom: 1px solid var(--border, #333340);++ max-height: 140px; overflow-y: auto;++ }++ .openvibe-navbar-dropdown-accounts .account-item {++ display: flex; align-items: center; gap: 8px; padding: 6px 8px;++ border-radius: 6px; cursor: pointer; font-size: 12px;++ color: var(--text-secondary, #b0b0b8); transition: background .12s;++ }++ .openvibe-navbar-dropdown-accounts .account-item:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }++ .openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; }++ .openvibe-navbar-dropdown-accounts .add-account {++ display: flex; align-items: center; gap: 8px; padding: 6px 8px;++ border-radius: 6px; cursor: pointer; font-size: 12px;++ color: var(--text-muted, #707080); transition: background .12s;++ text-decoration: none;++ }++ .openvibe-navbar-dropdown-accounts .add-account:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++++ .openvibe-navbar-dropdown-menu { padding: 6px 8px; }++ .openvibe-navbar-dropdown-menu a, .openvibe-navbar-dropdown-menu button {++ display: flex; align-items: center; gap: 8px; width: 100%;++ padding: 8px; border-radius: 6px; font-size: 12px; font-weight: 500;++ background: none; border: none; color: var(--text-primary, #e0e0e0);++ cursor: pointer; text-align: left; text-decoration: none;++ transition: background .12s;++ }++ .openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-navbar-dropdown-menu .danger { color: var(--live-red, #e74c3c); }++ .openvibe-navbar-dropdown-menu .icon { width: 18px; text-align: center; font-size: 14px; }++++ .openvibe-navbar .openvibe-network-badge {++ font-size: 10px; padding: 2px 8px; border-radius: 4px;++ background: rgba(59,130,246,0.1); color: var(--accent-light, #60a5fa);++ font-weight: 500; cursor: pointer; border: 1px solid transparent;++ transition: all .15s;++ }++ .openvibe-navbar .openvibe-network-badge:hover { border-color: var(--accent-dark, #2563eb); }++++ @media (max-width: 600px) {++ .openvibe-navbar-links { display: none; }++ .openvibe-navbar .openvibe-network-badge { display: none; }++ }++++ /* Touch and keyboard feedback. Phones flash a square, theme-blind blue box over anything++ tapped (-webkit-tap-highlight-color); the Frame turns it off and shows a slight press++ instead, and keyboard focus gets one ring in the theme accent. :where() keeps it at zero++ specificity, so the Frame's own :focus-visible rules and a host page's rules still win.++ \`scale\` is its own property, so it composes with any transform already in use. */++ .openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer { -webkit-tap-highlight-color: transparent; }++ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: .96; }++ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {++ outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px;++ }++ @media (prefers-reduced-motion: reduce) {++ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: none; opacity: .82; }++ }++ `;++ document.head.appendChild(s);++ }++++ // ── Icons ────────────────────────────────────────────────++ // The navbar draws its own icons. It used to borrow the page's Font Awesome, so on pages that do not load++ // that font every menu row had an empty slot. Known names get an inline SVG; an unknown name still falls++ // back to the page's icon font, so sites that pass their own Font Awesome icons keep working.++ // BEGIN generated by scripts/build-navbar-icons.js from nav-icons.js; do not edit by hand++ // Font Awesome Free 6 solid glyphs (CC BY 4.0, fontawesome.com/license/free) as filled paths,++ // viewBox 0 0 <w> 512: only the ones the navbar draws itself. The rest of the set is nav-icons.js.++ const NAV_ICONS = {++ 'fa-bell': [448, 'M224 11Q210 11 201 20Q192 29 192 43V62Q136 74 101 117Q65 160 64 219V238Q63 310 16 365L8 374Q-5 389 3 408Q12 426 32 427H416Q436 426 445 408Q453 389 440 374L433 365Q385 311 384 238V219Q383 160 347 117Q312 74 256 62V43Q256 29 247 20Q238 11 224 11ZM269 504Q288 485 288 459H224H160Q160 485 179 504Q198 523 224 523Q250 523 269 504Z'],++ 'fa-clock-rotate-left': [512, 'M75 86 41 52 75 86 41 52Q29 41 15 47Q1 52 0 69V179Q2 201 24 203H134Q151 202 156 188Q162 174 151 162L120 131Q174 77 256 75Q338 77 392 131Q446 185 448 267Q446 349 392 403Q338 457 256 459Q194 458 146 425Q135 417 122 419Q110 422 102 433Q94 444 96 456Q99 469 110 477Q173 522 256 523Q328 522 385 488Q443 454 477 396Q511 339 512 267Q511 195 477 138Q443 80 385 46Q328 12 256 11Q203 11 156 31Q110 51 75 86ZM256 139Q234 141 232 163V267Q232 277 239 284L311 356Q328 370 345 356Q359 339 345 322L280 257V163Q278 141 256 139Z'],++ 'fa-coins': [512, 'M512 91Q511 118 474 139Q428 164 351 170Q346 167 340 165Q279 140 192 139Q180 139 168 140L166 139Q129 118 128 91Q130 57 184 34Q238 12 320 11Q402 12 456 34Q510 57 512 91ZM161 172Q176 171 192 171Q239 171 278 180Q318 188 345 202Q383 223 384 251Q384 257 382 263Q374 283 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q346 299 346 299Q346 299 346 299Q319 314 279 322Q240 331 192 331Q100 330 44 302Q41 300 38 299Q1 278 0 251Q1 224 36 204Q71 184 128 176Q144 173 161 172ZM416 251Q414 218 392 198Q435 191 468 177Q493 167 512 152V187Q511 217 468 238Q446 249 416 256Q416 256 416 256Q416 254 416 251ZM384 347Q383 374 346 395Q344 396 342 397Q341 397 340 398Q284 426 192 427Q144 427 105 418Q65 410 38 395Q1 374 0 347V312Q19 327 44 337Q105 362 192 363Q279 362 340 337Q352 332 363 326Q372 321 380 315Q381 314 383 313Q383 312 384 312V315V321V347ZM416 347V315V347V315V289Q445 283 468 273Q493 263 512 248V283Q512 299 497 314Q471 339 416 352Q416 350 416 347ZM192 459Q279 458 340 433Q365 423 384 408V443Q382 477 328 500Q274 522 192 523Q110 522 56 500Q2 477 0 443V408Q19 423 44 433Q105 458 192 459Z'],++ 'fa-link': [640, 'M580 279Q622 234 622 176Q622 119 580 74Q541 37 489 32Q438 28 394 59L392 60Q381 68 379 81Q377 93 385 105Q393 115 405 117Q418 120 429 112L431 111Q456 94 484 96Q513 98 535 119Q558 144 558 176Q558 209 535 233L422 346Q397 369 365 369Q333 369 308 346Q287 324 285 295Q283 267 300 242L301 240Q308 229 306 217Q304 204 293 196Q282 188 270 190Q257 192 249 203L248 205Q217 249 221 301Q225 352 263 391Q308 433 365 433Q423 433 468 391L580 279ZM60 255Q18 300 18 358Q18 415 60 460Q99 497 151 502Q202 506 247 475L248 474Q259 466 261 453Q263 441 256 429Q247 419 235 417Q222 414 211 422L209 423Q184 440 156 438Q127 436 105 415Q82 390 82 358Q82 325 106 301L218 188Q243 165 275 165Q307 165 332 188Q353 210 355 239Q357 267 340 292L339 294Q332 305 334 318Q336 330 347 338Q358 346 370 344Q383 342 391 331L392 329Q423 285 419 233Q415 182 377 143Q332 101 275 101Q217 101 173 143L60 255Z'],++ 'fa-palette': [512, 'M512 267Q512 268 512 268Q512 269 512 270Q511 297 490 314Q470 331 442 331H344Q324 332 310 345Q297 359 296 379Q296 384 297 389Q300 403 307 417Q307 418 308 419Q318 439 320 461Q320 485 305 503Q291 521 267 523Q261 523 256 523Q184 522 127 488Q69 454 35 396Q1 339 0 267Q1 195 35 138Q69 80 127 46Q184 12 256 11Q328 12 385 46Q443 80 477 138Q511 195 512 267ZM128 299Q128 285 119 276Q110 267 96 267Q82 267 73 276Q64 285 64 299Q64 313 73 322Q82 331 96 331Q110 331 119 322Q128 313 128 299ZM128 203Q142 203 151 194Q160 185 160 171Q160 157 151 148Q142 139 128 139Q114 139 105 148Q96 157 96 171Q96 185 105 194Q114 203 128 203ZM288 107Q288 93 279 84Q270 75 256 75Q242 75 233 84Q224 93 224 107Q224 121 233 130Q242 139 256 139Q270 139 279 130Q288 121 288 107ZM384 203Q398 203 407 194Q416 185 416 171Q416 157 407 148Q398 139 384 139Q370 139 361 148Q352 157 352 171Q352 185 361 194Q370 203 384 203Z'],++ 'fa-plus': [448, 'M256 91Q256 77 247 68Q238 59 224 59Q210 59 201 68Q192 77 192 91V235H48Q34 235 25 244Q16 253 16 267Q16 281 25 290Q34 299 48 299H192V443Q192 457 201 466Q210 475 224 475Q238 475 247 466Q256 457 256 443V299H400Q414 299 423 290Q432 281 432 267Q432 253 423 244Q414 235 400 235H256V91Z'],++ 'fa-right-from-bracket': [512, 'M378 117 501 240 378 117 501 240Q512 251 512 267Q512 283 501 294L378 417Q368 427 354 427Q340 427 330 417Q320 407 320 393V331H192Q178 331 169 322Q160 313 160 299V235Q160 221 169 212Q178 203 192 203H320V141Q320 127 330 117Q340 107 354 107Q368 107 378 117ZM160 107H96H160H96Q82 107 73 116Q64 125 64 139V395Q64 409 73 418Q82 427 96 427H160Q174 427 183 436Q192 445 192 459Q192 473 183 482Q174 491 160 491H96Q55 490 28 463Q1 436 0 395V139Q1 98 28 71Q55 44 96 43H160Q174 43 183 52Q192 61 192 75Q192 89 183 98Q174 107 160 107Z'],++ 'fa-shield-halved': [512, 'M256 11Q263 11 269 14L458 94Q475 101 485 116Q496 131 496 151Q497 202 479 271Q461 340 414 405Q368 471 282 514Q256 526 230 514Q144 471 98 405Q51 340 33 271Q15 202 16 151Q16 131 27 116Q37 101 54 94L243 14Q249 11 256 11ZM256 78V456V78V456Q324 421 363 367Q401 313 417 256Q432 198 432 152L256 78Z'],++ 'fa-text-height': [576, 'M64 139V107V139V107H128V427H96Q82 427 73 436Q64 445 64 459Q64 473 73 482Q82 491 96 491H224Q238 491 247 482Q256 473 256 459Q256 445 247 436Q238 427 224 427H192V107H256V139Q256 153 265 162Q274 171 288 171Q302 171 311 162Q320 153 320 139V91Q319 71 306 57Q292 44 272 43H160H48Q28 44 14 57Q1 71 0 91V139Q0 153 9 162Q18 171 32 171Q46 171 55 162Q64 153 64 139ZM503 52Q493 43 480 43Q467 43 457 52L393 116Q379 132 386 151Q395 170 416 171H448V363H416Q395 364 386 383Q379 402 393 418L457 482Q467 491 480 491Q493 491 503 482L567 418Q581 402 574 383Q565 364 544 363H512V171H544Q565 170 574 151Q581 132 567 116L503 52Z'],++ 'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],++ 'fa-user': [448, 'M224 267Q259 267 288 250Q317 233 335 203Q352 173 352 139Q352 105 335 75Q317 45 288 28Q259 11 224 11Q189 11 160 28Q131 45 113 75Q96 105 96 139Q96 173 113 203Q131 233 160 250Q189 267 224 267ZM178 315Q103 317 52 367Q2 418 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q446 418 396 367Q345 317 270 315H178Z'],++ 'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],++ 'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],++ };++ // Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives).++ const NAV_ICONS_LAZY = {++ 320: 'fa-chevron-right',++ 384: 'fa-xmark fa-moon fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',++ 448: 'fa-book fa-hand-fist fa-bars fa-arrow-right fa-check fa-tag fa-bolt fa-font fa-lock fa-utensils',++ 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-chevron-down fa-magnifying-glass fa-gear fa-circle-half-stroke fa-sun fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-brain fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',++ 576: 'fa-house fa-id-card fa-video fa-star fa-tower-cell fa-diagram-project fa-money-bill-wave fa-crown fa-ticket fa-map',++ 640: 'fa-comments fa-gamepad fa-network-wired fa-people-arrows fa-people-group fa-shirt fa-code fa-robot fa-photo-film fa-wave-square fa-user-shield',++ };++ const NAV_ICONS_REV = 'bb866b6e2cf3'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)++ // END generated++ // Icons are solid filled paths generated from the Font Awesome Free solid font, so every OpenVibe++ // page gets the same crisp glyphs with no dependency on Font Awesome CSS. The glyphs the navbar++ // draws itself ship above; the rest of the set (nav-icons.js, ~16 KB brotli) is fetched once,++ // after first paint, the first time a site's link, chip or menu row names one. Until it lands++ // that icon is an empty <svg> of the glyph's exact size, then its path is filled in. A page that++ // loads nav-icons.js itself (before navbar.js) has every glyph from the first frame. Names not in++ // the set, and every lazy glyph if nav-icons.js cannot load, fall back to an <i class="fa-solid">++ // for pages that do load Font Awesome.++ const _selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';++ let _lazyWidth = null;++ let _iconState = 0; // 0 idle, 1 loading, 2 loaded, 3 unavailable++ function lazyWidth(name) {++ if (!_lazyWidth) { _lazyWidth = {}; for (const w in NAV_ICONS_LAZY) NAV_ICONS_LAZY[w].split(' ').forEach(n => { _lazyWidth[n] = Number(w); }); }++ return _lazyWidth[name] || 0;++ }++ function adoptIcons() {++ const set = root.OpenVibeNavIcons;++ if (!set || typeof set !== 'object') return false;++ Object.assign(NAV_ICONS, set); _iconState = 2;++ return true;++ }++ /**++ * A shared browser file next to this navbar.js: the same origin and the same release (a site that++ * serves its own pinned copy at /shared/ gets its own), else the network's copy.++ */++ function sibling(name) {++ const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);++ return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;++ }++ /** nav-icons.js next to this navbar.js (same origin, same release), else the network's copy. */++ function iconsUrl() {++ if (_config.iconsUrl) return _config.iconsUrl;++ return `${sibling('nav-icons.js')}?v=${NAV_ICONS_REV}`;++ }++ function iconSlots() {++ const out = new Set();++ [_navEl, typeof document !== 'undefined' ? document : null].forEach((r) => {++ try { if (r && r.querySelectorAll) r.querySelectorAll('svg[data-ovnav-ic]').forEach(el => out.add(el)); } catch { /* */ }++ });++ return out;++ }++ function fillIcons() {++ iconSlots().forEach((el) => {++ const g = NAV_ICONS[el.getAttribute('data-ovnav-ic')]; if (!g) return;++ el.removeAttribute('data-ovnav-ic'); el.removeAttribute('data-ovnav-cls');++ el.innerHTML = `<path fill="currentColor" d="${g[1]}"/>`;++ });++ }++ function iconsUnavailable() {++ _iconState = 3;++ iconSlots().forEach((el) => { try { el.outerHTML = `<i class="fa-solid ${escapeAttr(el.getAttribute('data-ovnav-cls'))}"></i>`; } catch { /* */ } });++ }++ function loadIcons() {++ if (_iconState) return; _iconState = 1;++ let started = false;++ const start = () => {++ if (started) return; started = true;++ if (adoptIcons()) return fillIcons();++ try {++ const sc = document.createElement('script'); sc.id = 'ov-nav-icons-loader'; sc.src = iconsUrl(); sc.async = true;++ try { sc.fetchPriority = 'low'; } catch { /* */ }++ sc.onload = () => { if (adoptIcons()) fillIcons(); else iconsUnavailable(); };++ sc.onerror = iconsUnavailable;++ document.head.appendChild(sc);++ } catch { iconsUnavailable(); }++ };++ // After first paint: the frame that shows the navbar goes out before the request does.++ try { requestAnimationFrame(() => setTimeout(start, 0)); } catch { /* */ }++ setTimeout(start, 300); // background tabs get no animation frames++ }++ function navIcon(cls) {++ if (!cls) return '';++ const key = String(cls).split(/\s+/).find(c => NAV_ICONS[c] || (_iconState !== 3 && lazyWidth(c)));++ if (!key) return `<i class="fa-solid ${escapeAttr(cls)}"></i>`;++ const g = NAV_ICONS[key] || (adoptIcons() ? NAV_ICONS[key] : null);++ const w = g ? g[0] : lazyWidth(key);++ const svg = `<svg class="ovnav-ic" viewBox="0 0 ${w} 512" style="width:${(w / 512).toFixed(3)}em" aria-hidden="true" focusable="false"`;++ if (g) return `${svg}><path fill="currentColor" d="${g[1]}"/></svg>`;++ loadIcons();++ return `${svg} data-ovnav-ic="${key}" data-ovnav-cls="${escapeAttr(cls)}"></svg>`;++ }++ function upgradeIconsWhenFontsReady() { /* icons no longer depend on webfonts */ }++++ // Release watch (ADR-016): a few seconds after the page settles, load release-watch.js from the++ // same place as this navbar, so every site that serves /release.json keeps open tabs current with++ // no page changes. Opt out with init({ releaseWatch: false }); an object is its OVReleaseConfig++ // ({ metricsUrl, url, inPlace }).++ function loadReleaseWatch() {++ try {++ const rw = _config.releaseWatch;++ if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;++ if (rw && typeof rw === 'object' && !root.OVReleaseConfig) root.OVReleaseConfig = rw;++ const sc = document.createElement('script'); sc.src = sibling('release-watch.js'); sc.async = true;++ try { sc.fetchPriority = 'low'; } catch { /* */ }++ document.head.appendChild(sc);++ } catch { /* optional */ }++ }++++ // ─── 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) : ''; }++ // Descriptive hosts ('youtube-downloader') are for search engines; the brand shows the name people use.++ const LONG_SUBS = { 'youtube-downloader': 'YT', youtubedownloader: 'YT', 'youtube-download': 'YT', youtube: 'YT', ytdl: 'YT' };++ function subLabel(sub) { return SUB_LABELS[sub] || SUB_LABELS[LONG_SUBS[sub] && LONG_SUBS[sub].toLowerCase()] || LONG_SUBS[sub] || titleCase(String(sub).replace(/-/g, ' ')).replace(/ /g, ''); }++++ /**++ * { 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>'}`;++ }++++++ // ── Frame data (https://openvibe.network/api/frame) ─────────────────────────────++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.++ const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {++ const KEY = 'ov_frame_v1', TTL = 30 * 60000;++ let inflight = null;++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };++ function clean(d) {++ if (!d || !Array.isArray(d.nav)) return null;++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const f = d.footer || {}, lg = f.legal || {};++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };++ }++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }++ function refresh() {++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);++ inflight = fetch('https://openvibe.network/api/frame?host=' + encodeURIComponent(host()), { credentials: 'omit' })++ .then(r => (r.ok ? r.json() : null)).then(clean)++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })++ .catch(() => null);++ return inflight;++ }++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */++ function get(onFirst) {++ const c = cached();++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }++ return c ? c.data : null;++ }++ return { get, refresh };++ })());++++ // ── Network launcher ─────────────────────────────────────++ const LAUNCHER_SITES = [++ { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },++ { 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.network/api/catalog.json'; // allowed by every site's CSP++ const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';++ 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_catalog2') || '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), go: t.hosts && t.hosts.short ? okUrl('https://' + t.hosts.short + '/') : null,++ hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter(h => typeof h === 'string' && h), id: String(t.id || ''), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };++ try { sessionStorage.setItem('ov_catalog2', 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.go || 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) {++ // Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),++ // the last row hands the query to the Tools search page, so typing never does nothing.++ const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);++ const siteList = (OVFrame.get() && OVFrame.get().nav.length ? OVFrame.get().nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES).filter(hit);++ const famList = fams.filter(hit);++ const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];++ const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;++ body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-fams">${siteList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')++ + (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')++ + `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;++ return;++ }++ const frame = OVFrame.get();++ const sites = frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;++ const soon = frame ? frame.soon : [];++ // On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people++ // can pick the one they will remember.++ const cur = currentHost().toLowerCase();++ const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;++ const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';++ body.innerHTML = addresses + `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>++ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>++ ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;++ }++ btn.addEventListener('click', async (e) => {++ e.stopPropagation();++ 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><div class="ovl-display" hidden></div>';++ bindDisplayControls(panel.querySelector('.ovl-display'));++ nav.appendChild(panel);++ regPanel(panel, 'launcher', close);++ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('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'); location.href = first ? first.href : TOOLS_SEARCH + encodeURIComponent(input.value.trim()); } 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 { /* */ }++ });++ }++++ /** One anonymous page-view count per page load (host only), which is what orders the network's++ * navigation. Skipped when the browser asks not to be tracked. */++ let _counted = false;++ function countView() {++ if (_counted || _config.countViews === false) return; _counted = true;++ try {++ if (navigator.globalPrivacyControl || navigator.doNotTrack === '1') return;++ if (!/^https:$/.test(location.protocol)) return;++ const url = 'https://openvibe.network/api/frame/hit';++ if (navigator.sendBeacon) navigator.sendBeacon(url);++ } catch { /* */ }++ }++++ /** Status chips beside the bell (balances, counters): [{ id, icon, value, valueId, title, tone, onClick, hidden }]. */++ function chipsHTML(chips, cls) {++ return (Array.isArray(chips) ? chips : []).filter(Boolean).map(c => `<button type="button" class="${cls || 'ovnav-chip'}" data-chip-id="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}${c.title ? ` title="${escapeAttr(c.title)}"` : ''}${c.hidden ? ' hidden' : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-v"${c.valueId ? ` id="${escapeAttr((cls ? 'menu-' : '') + c.valueId)}"` : ''}>${escapeAttr(c.value == null ? '' : c.value)}</span></button>`).join('');++ }++ function bindChips(scope) {++ (_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {++ if (!c || typeof c.onClick !== 'function') return;++ scope.querySelectorAll(`[data-chip-id="${c.id}"]`).forEach(el => { if (!el.__b) { el.__b = true; el.addEventListener('click', (e) => { e.stopPropagation(); c.onClick(e); }); } });++ });++ }++++ /** Link behaviour: per-link onClick, in-app navigation (onNavigate), dropdowns, the mobile drawer. */++ function bindLinks(nav, links) {++ const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l]));++ const drawer = nav.querySelector('#openvibe-drawer'), burger = nav.querySelector('#openvibe-burger');++ const closeDrawer = () => { if (drawer) drawer.classList.remove('open'); if (burger) burger.setAttribute('aria-expanded', 'false'); };++ nav.addEventListener('click', (e) => {++ const a = e.target.closest && e.target.closest('a.ovnav-link, .ovnav-net'); if (!a || !nav.contains(a)) return;++ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;++ const item = byId.get(a.getAttribute('data-link-id'));++ // A parent of a dropdown opens it on touch (there is no hover); a second tap follows the link.++ const dd = a.parentElement && a.parentElement.classList.contains('ovnav-dd') ? a.parentElement : null;++ if (dd && matchMedia('(hover: none)').matches && !dd.classList.contains('open')) { e.preventDefault(); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); dd.classList.add('open'); return; }++ if (item && typeof item.onClick === 'function') { const r = item.onClick(e); if (r === false) e.preventDefault(); closeDrawer(); if (e.defaultPrevented) return; }++ if (typeof _config.onNavigate === 'function' && a.target !== '_blank') {++ let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }++ if (same && _config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault();++ }++ closeDrawer();++ });++ document.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('.ovnav-dd')) nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); });++ if (burger && drawer) {++ burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); });++ document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); });++ regPanel(drawer, 'nav-drawer', closeDrawer);++ }++ }++++ /** Dropdown, launcher and notifications share one coordinator (panels.js): one open at a time, Escape, rotation.++ * Panels are registered where they are created (no page-wide observers); registrations queue until the script loads. */++ const _panelQueue = [];++ function regPanel(el, id, close) {++ if (!el) return;++ const opts = { el, openClass: 'open', id, close };++ if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);++ _panelQueue.push(opts);++ if (document.getElementById('ov-panels-loader')) return;++ const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = sibling('panels.js'); sc.async = true;++ sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); };++ document.head.appendChild(sc);++ }++++ /** Sites in the user menu: most used first (Frame data), never the one we are on. */++ function acrossHTML() {++ const frame = OVFrame.get();++ const here = currentHost().toLowerCase();++ const list = (frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, url: n.url, icon: n.icon || n.id })) : LAUNCHER_SITES)++ .filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h); } catch { return false; } }).slice(0, 5);++ return list.map(n => `<a href="${escapeAttr(n.url)}"><span class="icon"><span class="ov-icon" data-icon="${escapeAttr(n.icon)}" data-size="20" data-fx="none"></span></span> ${escapeAttr(n.name)}</a>`).join('');++ }++++ /** Display rows (text size, animations) cycle their value; state lives in theme-loader.js. */++ function bindDisplayRows(dropdown) {++ const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');++ if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }++ const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };++ const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; }); };++ rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));++ root.addEventListener('ov:display', paint); paint();++ }++++ /** OpenCoins balance in the menu header (one request when the menu first opens). */++ let _walletLoaded = false;++ async function loadWallet(dropdown) {++ if (_walletLoaded || !_config.token || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true;++ try {++ const r = await fetch(`${_config.apiBase}/api/coins/me`, { headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'include' });++ if (!r.ok) return; const j = await r.json(); const bal = Number(j.balance ?? (j.wallet && j.wallet.balance));++ const el = dropdown.querySelector('#openvibe-wallet'); if (!el || !isFinite(bal)) return;++ el.innerHTML = `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()}</a>`; el.hidden = false;++ } catch { /* the chip is optional */ }++ }++++ /** The network's most used sites, after the page's own links (networkLinks: false turns it off). */++ function networkLinksHTML(pageLinks, inDrawer) {++ if (_config.networkLinks === false) return '';++ const frame = OVFrame.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });++ if (!frame) return '';++ const here = currentHost().toLowerCase();++ const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));++ const max = inDrawer ? 6 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : 4);++ const pick = frame.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);++ if (!pick.length) return '';++ return (inDrawer ? '<div class="ud-label">Across OpenVibe</div>' : `<span class="ovnav-sep" aria-hidden="true"></span>`) + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');++ }++++ /** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */++ function bindDisplayControls(box) {++ const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;++ const paint = () => {++ const d = L.display.get();++ const seg = (key, val, label, title) => `<button type="button" data-k="${key}" data-v="${val}" aria-pressed="${String(d[key]) === val}" title="${title}">${label}</button>`;++ box.innerHTML = `<span class="ovl-dl">Display</span><span class="ovl-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>++ <span class="ovl-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span><a href="https://openvibe.network/themes">Themes</a>`;++ box.hidden = false;++ };++ box.addEventListener('click', (e) => { const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint(); });++ root.addEventListener('ov:display', paint);++ paint();++ }++++ // ── 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', realtime: _config.notificationsRealtime === true }); 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 = sibling('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);++ }++++ /** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */++ function sectionsHTML(sections) {++ return (Array.isArray(sections) ? sections : []).filter(sec => sec && Array.isArray(sec.items) && sec.items.length).map(sec =>++ `<div class="ud-sec"${sec.id ? ` data-sec-id="${escapeAttr(sec.id)}"` : ''}${sec.hidden ? ' hidden' : ''}>${sec.label ? `<div class="ud-label">${escapeAttr(sec.label)}</div>` : ''}${sec.items.map(menuItemHTML).join('')}</div><div class="sep"></div>`).join('');++ }++ const sectionItems = (sections) => (Array.isArray(sections) ? sections : []).reduce((o, sec) => o.concat((sec && sec.items) || []), []);++++ function menuItemHTML(item) {++ if (!item) return '';++ if (item.sep) return '<div class="sep"></div>';++ const icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';++ const cls = item.danger ? ' class="danger"' : '';++ const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}`;++ const val = item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : '';++ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}${val}</a>`;++ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}${val}</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(); const r = item.onClick(e); if (r === false) e.preventDefault(); if (item.keepOpen !== true) container.closest('.openvibe-navbar-dropdown')?.classList.remove('open'); });++ }++ }++++ /** One top-level link. Supports { label, href, icon, id, elId, page, hidden, external, dot, dotId, children: [...] }. */++ function linkHTML(l, inDrawer) {++ const attrs = `${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.elId ? ` id="${escapeAttr((inDrawer ? 'drawer-' : '') + l.elId)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}${l.hidden ? ' hidden' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}`;++ const kids = Array.isArray(l.children) ? l.children.filter(Boolean) : [];++ const inner = `${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(l.label)}</span>${l.dot !== undefined ? `<span class="ovnav-dot"${l.dotId && !inDrawer ? ` id="${escapeAttr(l.dotId)}"` : ''}${l.dot ? '' : ' hidden'}></span>` : ''}`;++ const a = `<a href="${escapeAttr(l.href || '#')}" class="ovnav-link${l.active ? ' active' : ''}"${attrs}>${inner}${kids.length && !inDrawer ? '<span class="ovnav-caret" aria-hidden="true">▾</span>' : ''}</a>`;++ if (!kids.length) return a;++ const menu = kids.map(k => `<a href="${escapeAttr(k.href || '#')}" class="ovnav-link ovnav-sublink"${k.id ? ` data-link-id="${escapeAttr(k.id)}"` : ''}${k.hidden ? ' hidden' : ''}>${k.icon ? `<span class="icon">${navIcon(k.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(k.label)}</span></a>`).join('');++ return inDrawer ? a + menu : `<div class="ovnav-dd"${l.hidden ? ' hidden' : ''}>${a}<div class="ovnav-dd-menu">${menu}</div></div>`;++ }++ const flatLinks = (list) => list.reduce((out, l) => out.concat([l], Array.isArray(l.children) ? l.children : []), []);++++ function currentLinks() {++ const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];++ const path = (typeof location !== 'undefined' && location.pathname) || '/';++ const activePage = _activePage;++ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (activePage ? l.page === activePage : (l.href === path && path !== '/')) }));++ }++++ let _activePage = null; // setActive(): single-page apps tell the navbar where they are++ const SERVICE_LINKS = {++ live: [++ { label: 'Watch', href: '/' },++ { label: 'Chat', href: '/chat' },++ { label: 'VODs', href: '/vods' },++ { label: 'Game', href: '/game' },++ ],++ tools: [++ { label: 'Home', href: '/' },++ ],++ games: [++ { label: 'Play', href: '/game' },++ { label: 'Canvas', href: '/canvas' },++ { label: 'Leaderboard', href: '/leaderboard' },++ ],++ media: [++ { label: 'Home', href: '/' },++ { label: 'Network', href: 'https://openvibe.network' },++ { label: 'Live', href: 'https://openvibe.live' },++ { label: 'Tools', href: 'https://openvibe.tools' },++ ],++ network: [++ { label: 'Home', href: '/' },++ { label: 'My Account', href: '/my' },++ { label: 'Themes', href: '/themes' },++ ],++ maps: [++ { label: 'Map', href: '/' },++ { label: 'Camps', href: '/camps' },++ ],++ food: [++ { label: 'Food Banks', href: '/' },++ { label: 'Meal Plan', href: '/#meal-plan' },++ ],++ img: [++ { label: 'Convert', href: 'https://convert.openvibe.tools' },++ { label: 'Compress', href: 'https://compress.openvibe.tools' },++ { label: 'Resize', href: 'https://resize.openvibe.tools' },++ { label: 'Crop', href: 'https://crop.openvibe.tools' },++ ],++ yt: [++ { label: 'Download', href: '/' },++ ],++ audio: [++ { label: 'Convert', href: 'https://audio.openvibe.tools' },++ { label: 'Trim', href: 'https://trim.openvibe.tools' },++ { label: 'Pitch', href: 'https://pitch.openvibe.tools' },++ { label: 'Reverb', href: 'https://reverb.openvibe.tools' },++ ],++ text: [++ { label: 'Fancy', href: 'https://fancy.openvibe.tools' },++ { label: 'Zalgo', href: 'https://zalgo.openvibe.tools' },++ { label: 'ASCII', href: 'https://ascii.openvibe.tools' },++ { label: 'Symbols', href: 'https://symbols.openvibe.tools' },++ ],++ logo: [++ { label: 'Title', href: 'https://title.openvibe.tools' },++ { label: 'Wordmark', href: 'https://wordmark.openvibe.tools' },++ { label: 'Badge', href: 'https://badge.openvibe.tools' },++ { label: 'Thumbnail', href: 'https://thumbnail.openvibe.tools' },++ ],++ docs: [++ { label: 'Merge', href: 'https://mergepdf.openvibe.tools' },++ { label: 'Split', href: 'https://splitpdf.openvibe.tools' },++ { label: 'Compress', href: 'https://compresspdf.openvibe.tools' },++ { label: 'Images→PDF', href: 'https://image2pdf.openvibe.tools' },++ ],++ net: [++ { label: 'Lookup', href: 'https://lookup.openvibe.tools' },++ { label: 'My IP', href: 'https://myip.openvibe.tools' },++ { label: 'DNS', href: 'https://dns.openvibe.tools' },++ { label: 'Ping', href: 'https://ping.openvibe.tools' },++ { label: 'SSL', href: 'https://ssl.openvibe.tools' },++ ],++ dev: [++ { label: 'JSON', href: 'https://json.openvibe.tools' },++ { label: 'Base64', href: 'https://base64.openvibe.tools' },++ { label: 'JWT', href: 'https://jwt.openvibe.tools' },++ { label: 'Regex', href: 'https://regex.openvibe.tools' },++ { label: 'Diff', href: 'https://diff.openvibe.tools' },++ ],++ };++++ function getAccounts() {++ try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }++ }++++ // ─── SSO auth resolution ───────────────────────────────────++ // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,++ // and Domain=.openvibe.tools on the tools apex + every tool subdomain.++ // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,++ // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,++ // img., yt., docs., food., …) do NOT mount /auth/* — they only read the++ // shared cookie — so sign-in/out always goes through the gateway apex.++ // Detection order: ov_token cookie → localStorage → page-provided token →++ // app-specific session endpoint (opt-in via config.sessionUrl).++ const TOOLS_APEX = 'openvibe.tools';++ const TOKEN_KEY = 'ov_token';++++ function currentHost() {++ return (typeof location !== 'undefined' && location.hostname) || '';++ }++++ function onToolsDomain(host) {++ const h = host !== undefined ? host : currentHost();++ return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX);++ }++++ function onNetworkDomain(host) {++ const h = host !== undefined ? host : currentHost();++ try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; }++ }++++ function parseCookieToken(cookieStr) {++ const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/);++ if (!m || !m[1]) return null;++ try { return decodeURIComponent(m[1]); } catch { return m[1]; }++ }++++ function getCookieToken() {++ if (typeof document === 'undefined') return null;++ return parseCookieToken(document.cookie);++ }++++ function getStoredToken() {++ try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; }++ }++++ function resolveToken() {++ return getCookieToken() || getStoredToken() || _config.token || null;++ }++++ /** Where "Sign In" goes on this host. */++ function resolveLoginHref(host, returnUrl) {++ if (_config.loginUrl) return fillUrl(_config.loginUrl, returnUrl);++ if (onToolsDomain(host)) {++ // Every *.openvibe.tools host signs in through the gateway apex: it++ // holds the OAuth state cookie + redirect_uri (both apex-host-only)++ // and sets ov_token with Domain=.openvibe.tools, so the session++ // reaches all satellites. A host-local /auth/login would 404 on++ // satellites and break the OAuth state check on gateway subdomains.++ return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;++ }++ if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;++ return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`;++ }++++ function setAuthCookie(token) {++ if (typeof document === 'undefined') return;++ const maxAge = 24 * 60 * 60; // matches the 24h access JWT++ if (onToolsDomain()) {++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`;++ } else {++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`;++ }++ }++++ function clearAuthState() {++ if (typeof document !== 'undefined') {++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';++ if (onToolsDomain()) {++ document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`;++ }++ }++ try {++ localStorage.removeItem(TOKEN_KEY);++ localStorage.removeItem('openvibe_anon_token');++ localStorage.removeItem('openvibe_active_account');++ } catch { /* storage unavailable */ }++ }++++ function persistToken(token) {++ try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ }++ setAuthCookie(token);++ }++++ /** GET {apiBase}/api/auth/me with the Bearer token. */++ async function fetchMe(token) {++ try {++ const res = await fetch(`${_config.apiBase}/api/auth/me`, {++ headers: { Authorization: `Bearer ${token}` },++ });++ if (res.ok) {++ const data = await res.json();++ return { ok: true, user: (data && (data.user || data)) || null };++ }++ return { ok: false, unauthorized: res.status === 401 || res.status === 403 };++ } catch {++ return { ok: false, unauthorized: false };++ }++ }++++ /** loginUrl/logoutUrl templates: {url} = the full return URL, {path} = its path + query (for sites that only accept local next=). */++ function fillUrl(template, returnUrl) {++ const full = returnUrl || (typeof location !== 'undefined' ? location.href : '/');++ let path = '/';++ try { const u = new URL(full, typeof location !== 'undefined' ? location.href : 'https://openvibe.network/'); path = u.pathname + u.search; } catch { /* */ }++ return String(template).replace('{url}', encodeURIComponent(full)).replace('{path}', encodeURIComponent(path));++ }++++ /** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */++ async function gatewayRefresh() {++ try {++ const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' });++ if (!res.ok) return null;++ const data = await res.json();++ return data && data.token ? data : null;++ } catch { return null; }++ }++++ /** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */++ async function networkRefresh(token) {++ try {++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {++ method: 'POST',++ headers: { Authorization: `Bearer ${token}` },++ });++ if (!res.ok) return null;++ const data = await res.json();++ return data && data.token ? data : null;++ } catch { return null; }++ }++++ /**++ * Resolve the signed-in user from the shared SSO state.++ * With a token: validate it against the Network. If it is rejected, make++ * ONE refresh attempt (same-origin gateway refresh first on tools hosts,++ * then the Network's grace refresh) before giving up — never leave a++ * stale "Sign In" when a recoverable session exists.++ */++ async function resolveSessionUser() {++ const token = resolveToken();++ if (token) {++ const me = await fetchMe(token);++ if (me.ok && me.user) return { user: me.user, token };++ if (me.unauthorized) {++ if (onToolsDomain()) {++ const r = await gatewayRefresh();++ if (r) {++ const user = r.user || (await fetchMe(r.token)).user;++ if (user) { persistToken(r.token); return { user, token: r.token }; }++ }++ }++ const n = await networkRefresh(token);++ if (n) {++ const user = n.user || (await fetchMe(n.token)).user;++ if (user) { persistToken(n.token); return { user, token: n.token }; }++ }++ // A stored token nobody accepts any more: drop it, so it cannot keep hiding a++ // good session (the site's own below, or the next sign-in).++ try { if (localStorage.getItem('ov_token') === token) localStorage.removeItem('ov_token'); } catch { /* */ }++ }++ if (!_config.sessionUrl) return null;++ }++ // No usable token — ask the page's own session endpoint if it has one (a site with a++ // server-side session, such as openvibe.blog, knows who is signed in even when this++ // browser's stored token is stale or was never shared with the page).++ if (_config.sessionUrl) {++ try {++ const res = await fetch(_config.sessionUrl, { credentials: 'include' });++ if (res.ok) {++ const data = await res.json();++ const user = (data && (data.user || data)) || null;++ if (user && (user.username || user.id)) return { user, token: null };++ }++ } catch { /* signed out */ }++ }++ return null;++ }++++ 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 = sibling('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 = sibling('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()++ .then((session) => {++ _authInFlight = null;++ if (session && session.user) {++ _config.user = session.user;++ if (session.token) _config.token = session.token;++ render();++ recordHistory();++ enableHandoff();++ } else {++ maybeSilentLogin();++ }++ try {++ document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {++ detail: { user: session ? session.user : null, token: session ? session.token : null },++ }));++ } catch { /* non-DOM environment */ }++ return session ? session.user : null;++ })++ .catch(() => { _authInFlight = null; return null; });++ return _authInFlight;++ }++++ function escapeAttr(value) {++ return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');++ }++++ function getAvatarInitial(user) {++ const source = user?.display_name || user?.username || 'O';++ return String(source).trim().charAt(0).toUpperCase() || 'O';++ }++++ function makeAvatarPlaceholder(user, size = 64) {++ const initial = getAvatarInitial(user);++ const bg = user?.profile_color || '#3b82f6';++ const svg = `++ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">++ <rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/>++ <text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="${Math.round(size * 0.42)}" font-weight="700" fill="#ffffff">${initial}</text>++ </svg>`;++ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;++ }++++ function avatarSrc(user, size = 64) {++ return user?.avatar_url || makeAvatarPlaceholder(user, size);++ }++++ function avatarImg(user, size = 64, className = 'openvibe-navbar-avatar', id = '') {++ const fallback = makeAvatarPlaceholder(user, size);++ const idAttr = id ? ` id="${escapeAttr(id)}"` : '';++ const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');++ return `<img class="${escapeAttr(className)}" src="${escapeAttr(avatarSrc(user, size))}" data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;++ }++++ function attachAvatarFallbacks(rootEl) {++ rootEl?.querySelectorAll('img[data-fallback-src]').forEach((img) => {++ img.addEventListener('error', () => {++ const fallback = img.dataset.fallbackSrc;++ if (fallback && img.src !== fallback) {++ img.src = fallback;++ }++ }, { once: true });++ });++ }++++ /**++ * "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 = sibling('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() {++ // A site may put state classes on the bar (Live's transparent hero mode). A re-render must not lose them.++ let carried = []; try { carried = Array.from((_navEl && _navEl.classList) || []).filter(c => c !== 'openvibe-navbar'); } catch { carried = []; }++ if (_navEl) _navEl.remove();++++ const nav = document.createElement('nav');++ nav.className = 'openvibe-navbar';++ carried.forEach(c => { try { nav.classList.add(c); } catch { /* */ } });++ const svc = _config.service;++++ 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();++ const isAnon = u && u.is_anon;++ 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 = sibling('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }++ }++ nav.innerHTML = `++ ${brandHTML(brand)}++ <div class="openvibe-navbar-links">++ ${links.map(l => linkHTML(l, false)).join('')}++ ${networkLinksHTML(links)}++ ${u && u.role === 'admin' && _config.adminLink !== false && !flatLinks(links).some(l => l.id === 'admin') ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} Admin</a>` : ''}++ </div>++ <div class="openvibe-navbar-spacer"></div>++ <div class="openvibe-navbar-right">++ ${u ? chipsHTML(_config.chips) : ''}++ <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>`}++ ${links.length ? '<button type="button" class="ovnav-burger" id="openvibe-burger" aria-label="Menu" aria-haspopup="true" aria-expanded="false"><span></span><span></span><span></span></button>' : ''}++ </div>++ ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${links.map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div></div>` : ''}++ `;++ if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));++ bindLinks(nav, links);++ bindChips(nav);++ bindLauncher(nav);++ countView();++ // Pages that wire the bell themselves run right after init(); give them the first go.++ setTimeout(() => mountBell(nav, u), 0);++++ // Dropdown++ if (u) {++ const dropdown = document.createElement('div');++ dropdown.className = 'openvibe-navbar-dropdown';++ dropdown.id = 'openvibe-user-dropdown';++++ const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));++ const menuCfg = _config.menu || {};++ 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">++ ${avatarImg(u, 72, '', '')}++ <div class="info">++ <div class="name">${escapeAttr(u.display_name || u.username)}</div>++ <div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>++ ${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div>'}++ ${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}++ </div>++ </div>++ <div class="openvibe-navbar-dropdown-accounts"${_config.accounts === false ? ' hidden' : ''}>++ ${otherAccounts.map(a => `++ <div class="account-item" data-account-id="${a.id}">++ ${avatarImg(a, 48, '', '')}++ <span>${a.display_name || a.username}${a.is_anon ? ' (anon)' : ''}</span>++ </div>++ `).join('')}++ <div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">++ <span style="width:24px;text-align:center">${navIcon('fa-user-secret')}</span>++ <span>Switch to Anonymous</span>++ </div>++ <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">++ <span style="width:24px;text-align:center">${navIcon('fa-plus')}</span>++ <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.length ? `<div class="ud-label">${escapeAttr((_config.menu && _config.menu.label) || brand.short || 'This site')}</div>${before.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}++ ${sectionsHTML(menuCfg.sections)}++ ${menuCfg.defaults === false ? '' : `<div class="ud-label">You</div>++ <a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>++ ${!isAnon && u.username ? `<a href="https://openvibe.live/@${escapeAttr(u.username)}"><span class="icon">${navIcon('fa-tower-broadcast')}</span> My Channel</a>` : ''}++ <a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>++ <div class="sep"></div>`}++ <div class="ud-label">Display</div>++ <button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button>++ <button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>++ <a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>++ <div class="sep"></div>++ <div class="ud-label">Across OpenVibe</div>++ ${acrossHTML()}++ ${menuCfg.defaults === false ? '' : `++ <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>++ <a href="https://openvibe.network/my#linked"><span class="icon">${navIcon('fa-link')}</span> Linked Services</a>++ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-shield-halved')}</span> Admin Panel</a>` : ''}`}++ ${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}++ <div class="sep"></div>++ <button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button>++ </div>++ `;++ nav.appendChild(dropdown);++ bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));++ bindChips(dropdown);++ if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same) { if (_config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });++ bindDisplayRows(dropdown);++ regPanel(dropdown, 'user-menu');++ if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));++++ // Avatar click toggles dropdown++ nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {++ dropdown.classList.toggle('open');++ if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; if (root.OpenVibeIcons) root.OpenVibeIcons.mount(dropdown); else if (!document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } }++ });++++ // Close on outside click++ document.addEventListener('click', e => {++ if (!nav.contains(e.target)) dropdown.classList.remove('open');++ });++++ // Account switching++ dropdown.querySelectorAll('[data-account-id]').forEach(el => {++ el.addEventListener('click', () => {++ const id = el.dataset.accountId;++ document.dispatchEvent(new CustomEvent('openvibe-switch-account', { detail: { accountId: id } }));++ dropdown.classList.remove('open');++ });++ });++++ dropdown.querySelector('#openvibe-logout-btn')?.addEventListener('click', () => {++ dropdown.classList.remove('open');++ if (_config.onLogout) _config.onLogout();++ else {++ clearAuthState();++ _config.user = null;++ _config.token = null;++ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }++ if (_config.logoutUrl) {++ // The site ends its own server-side session too, then sends us back.++ window.location.href = fillUrl(_config.logoutUrl, window.location.href);++ } else if (onToolsDomain()) {++ // The gateway also clears the Domain=.openvibe.tools cookie++ // and the httpOnly refresh cookie, then sends us back here.++ window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;++ } else {++ window.location.reload();++ }++ }++ });++ } else {++ nav.querySelector('#openvibe-login-btn')?.addEventListener('click', (event) => {++ if (!_config.onLogin) return;++ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;++ event.preventDefault();++ _config.onLogin();++ });++ }++++ // Insert into page — use navbar-mount placeholder if available, otherwise prepend to body++ const mount = document.getElementById('navbar-mount');++ if (mount) {++ mount.appendChild(nav);++ } else {++ document.body.prepend(nav);++ }++ _navEl = nav;++ attachAvatarFallbacks(nav);++ return nav;++ }++++ const OpenVibeNavbar = {++ init(opts = {}) {++ Object.assign(_config, opts);++ injectStyles();++ const el = render();++ upgradeIconsWhenFontsReady();++ if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);++ // Pages that hand us a resolved user (openvibe.network, the tools++ // gateway hub) keep full control. Everyone else — pages that pass++ // only a token, or nothing at all — gets the user resolved from++ // the shared SSO state (ov_token cookie / localStorage / optional++ // sessionUrl) and the navbar re-renders when it arrives.++ // auth: 'external' — the site signs people in itself (Live) and tells us with setUser().++ if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }++ else 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(); },++++ /** Update user (after account switch). */++ setUser(user) {++ _config.user = user;++ 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 signed-in user changed (sites with auth: 'external'). Pass null for signed out. */++ setUser(user, token) { _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; return render(); },++ /** Single-page apps: mark the link whose `page` matches as active. */++ setActive(page) { _activePage = page || null; if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); },++ /** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */++ setChip(id, value, patch) {++ const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];++ lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));++ document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; });++ },++ /** Patch a top-level or dropdown link by id: updateLink('broadcast', { hidden: false, dot: true, label }). */++ updateLink(id, patch) {++ const list = _runtimeLinks || _config.links || [];++ const l = flatLinks(list).find(x => x && x.id === id); if (!l) return; Object.assign(l, patch || {});++ if (!_navEl) return;++ _navEl.querySelectorAll(`[data-link-id="${id}"]`).forEach(a => {++ if ('hidden' in patch) { a.hidden = !!patch.hidden; const dd = a.parentElement; if (dd && dd.classList.contains('ovnav-dd')) dd.hidden = !!patch.hidden; }++ if ('label' in patch) { const t = a.querySelector('.ovnav-l'); if (t) t.textContent = patch.label; }++ if ('href' in patch) a.setAttribute('href', patch.href);++ if ('dot' in patch) { const d = a.querySelector('.ovnav-dot'); if (d) d.hidden = !patch.dot; }++ });++ },++ /** Patch a custom menu item by id: updateMenuItem('admin', { hidden: false, value: '3' }). */++ updateMenuItem(id, patch) {++ const item = sectionItems(_config.menu && _config.menu.sections).find(x => x && x.id === id); if (item) Object.assign(item, patch || {});++ document.querySelectorAll(`.openvibe-navbar-dropdown [data-menu-id="${id}"]`).forEach(el => { if ('hidden' in patch) el.hidden = !!patch.hidden; if ('value' in patch) { const v = el.querySelector('.ud-val'); if (v) v.textContent = patch.value; } });++ },++++ /** 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. */++ getBellMount() {++ return _navEl?.querySelector('#openvibe-bell-mount') || null;++ },++++ getElement() { return _navEl; },++++ destroy() {++ _navEl?.remove();++ _navEl = null;++ },++++ /** Internal auth helpers — exposed for tests, not a public API. */++ _auth: {++ parseCookieToken,++ onToolsDomain,++ onNetworkDomain,++ resolveLoginHref,++ resolveToken,++ resolveSessionUser,++ clearAuthState,++ persistToken,++ fillUrl,++ },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;++ else root.OpenVibeNavbar = OpenVibeNavbar;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,118 @@++/*++ * openvibe-shared/notification-live.js — the notification badge's realtime feed (roadmap WS-E task 3, WS-F task 1;++ * ADR-005 amendment 2). notification-ui.js loads it on demand when a site turns it on (navbar notificationsRealtime).++ * For every (re)connect it asks Network for a realtime ticket (POST <api>/api/v1/realtime/ticket: Bearer token, or the++ * cookie on openvibe.network itself) and opens Events' stream with it, without credentials:++ * <stream_url>?topics=network.notification.*&ticket=<ticket>[&last_event_id=<seq>]++ * Events streams only the person's own network.notification.created events (subject visibility); one whose subject is++ * not the ticket's is ignored all the same. Each calls onNotification(payload); `event: gap` calls onGap.++ * A ticket opens one stream: errors close it and reconnect with a fresh ticket from the cursor, backing off 2 s to 15 min++ * with jitter. Stops (the caller's polling is all there is) after 10 failures in a row, on a Content-Security-Policy++ * refusal, when signed out (401/403) or when Network has realtime off (404/503). Hidden 5 min: closed; shown: resumed.++ * OVNotificationLive.create({ ticketUrl, token, credentials, onNotification, onGap, onState }) → { start, stop, restart, state }++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OVNotificationLive) return;++ const TYPE_RE = /^network\.notification\./;++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++ const MAX_FAILURES = 10; // 2 s, 4 s, … 512 s: about 9 to 17 minutes of retries before polling only++++ function create(o) {++ const rt = { state: 'off', failures: 0, lastSeq: null, subject: null, origin: null, events: 0, ignored: 0, gaps: 0, tickets: 0 };++ let es = null; let stopped = true; let gen = 0;++ const t = {}; // timers: retry, hide++ const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); };++ const call = (f, ...a) => { try { if (typeof f === 'function') f(...a); } catch { /* the page's handler must not break the feed */ } };++ function set(s) { if (rt.state !== s) { rt.state = s; call(o.onState, s); } }++ function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }++ function halt(s) { close(); root.clearTimeout(t.retry); t.retry = null; set(s); }++++ async function open() {++ if (stopped || es || rt.state === 'ticket') return;++ if (document.hidden) { set('hidden'); return; }++ const g = gen;++ set('ticket');++ let tk;++ try {++ const tok = typeof o.token === 'function' ? o.token() : o.token;++ const r = await root.fetch(o.ticketUrl, { method: 'POST', credentials: o.credentials || 'omit', cache: 'no-store', headers: tok ? { Authorization: `Bearer ${tok}` } : {} });++ if (g !== gen) return;++ if (r.status === 401 || r.status === 403) return halt('signed-out');++ if (r.status === 404 || r.status === 501 || r.status === 503) return halt('unavailable');++ if (!r.ok) throw new Error('ticket');++ tk = await r.json();++ if (!tk || typeof tk.ticket !== 'string' || !SUBJECT_RE.test(String(tk.subject)) || !Array.isArray(tk.topics)) throw new Error('ticket');++ } catch {++ if (g === gen) fail();++ return;++ }++ if (g !== gen) return;++ let u;++ try {++ u = new URL(tk.stream_url);++ if (!/^https?:$/.test(u.protocol)) throw new Error('url');++ u.searchParams.set('topics', tk.topics.join(','));++ u.searchParams.set('ticket', tk.ticket);++ if (rt.lastSeq != null) u.searchParams.set('last_event_id', String(rt.lastSeq));++ } catch { fail(); return; }++ rt.tickets++; rt.origin = u.origin;++ rt.subject = tk.subject;++ set('connecting');++ let s;++ try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; }++ s.onopen = () => { if (es === s) { rt.failures = 0; set('open'); } };++ s.onmessage = (e) => { if (es === s) heard(e); };++ s.onerror = () => { if (es === s) fail(); };++ s.addEventListener('gap', (e) => {++ if (es !== s) return;++ let gap = null; try { gap = JSON.parse(e.data); } catch { /* */ }++ rt.gaps++;++ call(o.onGap, gap);++ });++ }++ function heard(e) {++ let m; try { m = JSON.parse(e.data); } catch { return; }++ const seq = m && typeof m.seq === 'number' ? m.seq : Number(e.lastEventId);++ if (Number.isFinite(seq)) { if (rt.lastSeq != null && seq <= rt.lastSeq) return; rt.lastSeq = seq; }++ const ev = m && m.event;++ // Events already sends only this person's own; anything else is never shown.++ if (!ev || !TYPE_RE.test(String(ev.event_type)) || !ev.subject || ev.subject.type !== 'user' || ev.subject.id !== rt.subject) { rt.ignored++; return; }++ rt.events++;++ call(o.onNotification, ev.payload && typeof ev.payload === 'object' ? ev.payload : {}, ev);++ }++ // A ticket opens one stream, so the browser's own reconnect (same URL) is never used: close, back off, new ticket.++ function fail() {++ close();++ if (stopped || rt.state === 'blocked') return;++ if (++rt.failures >= MAX_FAILURES) { set('failed'); return; }++ set('backoff');++ later('retry', () => { if (!stopped) { if (document.hidden) set('hidden'); else open(); } }, Math.min(9e5, 2e3 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));++ }++++ // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.++ document.addEventListener('securitypolicyviolation', (e) => {++ if (stopped || !rt.origin || String(e.blockedURI || '').indexOf(rt.origin) !== 0) return;++ halt('blocked');++ });++ document.addEventListener('visibilitychange', () => {++ if (stopped) return;++ if (document.hidden) { if (es) later('hide', () => { if (document.hidden && es) { close(); set('hidden'); } }, 5 * 60 * 1000); return; }++ root.clearTimeout(t.hide); t.hide = null;++ if (rt.state === 'hidden') open();++ });++ root.addEventListener('online', () => { if (!stopped && (rt.state === 'failed' || rt.state === 'backoff')) { rt.failures = 0; root.clearTimeout(t.retry); set('off'); open(); } });++++ const api = {++ start() { if (!stopped) return api; stopped = false; set('off'); open(); return api; },++ stop() { stopped = true; gen++; close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); set('off'); return api; },++ /** Another account (or a new token): forget the cursor and the subject, start again. */++ restart() { api.stop(); rt.lastSeq = null; rt.subject = null; rt.failures = 0; return api.start(); },++ state: () => ({ ...rt }),++ };++ return api;++ }++++ root.OVNotificationLive = { create };++ if (typeof module !== 'undefined' && module.exports) module.exports = root.OVNotificationLive;++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,765 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Notification UI Client (shared across every OpenVibe site)++//++// One store (openvibe.network) → one bell everywhere. This file renders the bell +++// dropdown panel, toasts, a full-page inbox, and handles Web Push subscription.++//++// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>++// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })++// OpenVibeNotifications.createBell(mountEl)++// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox++//++// Reads: GET /api/notifications?limit&offset&unread_only&category&q&since++// GET /api/notifications/unread-count++// Realtime (init({ realtime: true }), off by default): notification-live.js, loaded from beside this file, hears the++// person's network.notification.created over OpenVibe.Events with a ticket from POST /api/v1/realtime/ticket;++// each event or gap re-reads the count. Polling stays: every 15 s without a stream, every 2 min with one.++// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ const POLL_INTERVAL = 15_000;++ const REALTIME_POLL = 120_000; // with an open realtime stream the poll is only a safety net (reads made on other sites)++ const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';++ const PAGE_SIZE = 30;++ const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = sticky++ const MAX_TOASTS = 3;++ const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };++ const CATEGORY_LABELS = {++ social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams', economy: 'Economy',++ achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements',++ };++ const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network' };++++ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };++ let _pollTimer = null;++ let _lastSeenAt = null; // newest created_at we have toasted / seen++ let _unreadCount = 0;++ let _toastContainer = null;++ let _panelEl = null;++ let _bellEl = null;++ let _audioCache = {};++ let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };++ let _channel = null; // BroadcastChannel for same-origin tab sync++ let _live = null; // OVNotificationLive feed (realtime on)++ let _lastPoll = 0;++ let _soon = null; let _soonAll = false;++ const _views = new Map(); // container → view state (panel + inboxes)++++ // ── Styles ──────────────────────────────────────────────++ function injectStyles() {++ if (document.getElementById('openvibe-notif-styles')) return;++ const style = document.createElement('style');++ style.id = 'openvibe-notif-styles';++ style.textContent = `++ @keyframes openvibe-toast-in { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }++ @keyframes openvibe-toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }++ @keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }++ @keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }++ @keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }++ @keyframes openvibe-spin { to { transform: rotate(360deg); } }++++ .openvibe-toast-container { position: fixed; top: 82px; right: 14px; z-index: 100000; display: flex; flex-direction: column; gap: 6px; pointer-events: none; max-width: 340px; width: 100%; }++ .openvibe-toast { pointer-events: all; background: color-mix(in srgb, var(--bg-card, #22222c) 94%, transparent); border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent); border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 10px 24px rgba(0,0,0,0.28); animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1); cursor: pointer; position: relative; overflow: hidden; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); transition: border-color .2s; }++ .openvibe-toast:hover { border-color: var(--accent, #3b82f6); }++ .openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }++ .openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }++ .openvibe-toast-icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }++ .openvibe-toast-body { flex: 1; min-width: 0; }++ .openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }++ .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #60a5fa); font-weight: 500; }++ .openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++ .openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }++ .openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }++ .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #3b82f6); }++ .openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }++ .openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }++ .openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }++ .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; }++ .openvibe-toast-actions button.primary { background: var(--accent, #3b82f6); color: #fff; border-color: var(--accent, #3b82f6); }++++ .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }++ .openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }++ .openvibe-bell:hover svg, .openvibe-bell.open svg { fill: var(--accent-light, #60a5fa); }++ .openvibe-bell .badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; background: var(--live-red, #e74c3c); color: #fff; border-radius: 8px; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; animation: openvibe-badge-pulse .4s ease; }++ .openvibe-bell .badge.hidden { display: none; }++ .openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }++++ .openvibe-notif-panel { position: fixed; top: 62px; right: 16px; width: 400px; max-height: calc(100vh - 86px); background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5)); z-index: 99999; display: none; flex-direction: column; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); animation: openvibe-slide-down .2s ease; overflow: hidden; }++ .openvibe-notif-panel.open { display: flex; }++ .openvibe-notif-inbox { display: flex; flex-direction: column; background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; overflow: hidden; color: var(--text-primary, #e0e0e0); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; min-height: 420px; max-height: 78vh; }++++ .openvibe-nv-head { padding: 12px 14px; border-bottom: 1px solid var(--border, #333340); display: flex; align-items: center; justify-content: space-between; gap: 8px; }++ .openvibe-nv-head h3 { font-size: 15px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }++ .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 600; background: var(--live-red, #e74c3c); color: #fff; border-radius: 9px; padding: 1px 7px; }++ .openvibe-nv-head h3 .count.zero { display: none; }++ .openvibe-nv-actions { display: flex; gap: 4px; align-items: center; }++ .openvibe-nv-actions button, .openvibe-nv-actions a { background: none; border: 1px solid transparent; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 5px 8px; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; line-height: 1; }++ .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++ .openvibe-nv-actions button.on { color: var(--accent-light, #60a5fa); border-color: color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent); }++ .openvibe-nv-actions button:disabled { opacity: .45; cursor: default; }++ .openvibe-nv-actions svg { width: 14px; height: 14px; fill: currentColor; }++++ .openvibe-nv-tools { padding: 8px 12px; border-bottom: 1px solid var(--border, #333340); display: flex; flex-direction: column; gap: 8px; }++ .openvibe-nv-search { position: relative; }++ .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 7px 28px 7px 30px; border-radius: 8px; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-primary, #e0e0e0); font-size: 12px; outline: none; }++ .openvibe-nv-search input:focus { border-color: var(--accent, #3b82f6); }++ .openvibe-nv-search svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; fill: var(--text-muted, #707080); pointer-events: none; }++ .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; display: none; }++ .openvibe-nv-search.has-q .clear { display: block; }++ .openvibe-nv-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }++ .openvibe-nv-chips button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 10px; border-radius: 999px; cursor: pointer; line-height: 1.2; }++ .openvibe-nv-chips button:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e0e0e0); }++ .openvibe-nv-chips button.active { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); border-color: var(--accent, #3b82f6); color: var(--accent-light, #60a5fa); }++ .openvibe-nv-chips select { margin-left: auto; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 8px; border-radius: 8px; cursor: pointer; }++++ .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 4px 0; overscroll-behavior: contain; }++ .openvibe-notif-item { padding: 10px 12px 10px 14px; display: flex; gap: 10px; align-items: flex-start; cursor: pointer; transition: background .15s; border-left: 3px solid transparent; position: relative; }++ .openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); border-left-color: var(--accent, #3b82f6); }++ .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; width: 32px; text-align: center; }++ .openvibe-notif-item .icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }++ .openvibe-notif-item .content { flex: 1; min-width: 0; }++ .openvibe-notif-item .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; padding-right: 18px; }++ .openvibe-notif-item.unread .title { font-weight: 650; }++ .openvibe-notif-item .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++ .openvibe-notif-item .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }++ .openvibe-notif-item .meta .tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }++ .openvibe-notif-item .dismiss { position: absolute; top: 8px; right: 8px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; opacity: 0; transition: opacity .15s; }++ .openvibe-notif-item:hover .dismiss { opacity: 1; }++ .openvibe-notif-item .dismiss:hover { color: var(--live-red, #e74c3c); background: var(--bg-tertiary, #2a2a38); }++ .openvibe-nv-empty { text-align: center; padding: 44px 24px; color: var(--text-muted, #707080); font-size: 13px; }++ .openvibe-nv-empty .icon { font-size: 34px; margin-bottom: 8px; display: block; opacity: .5; }++ .openvibe-nv-more { display: flex; justify-content: center; padding: 10px; }++ .openvibe-nv-more button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 12px; padding: 6px 14px; border-radius: 8px; cursor: pointer; }++ .openvibe-nv-more .spinner { width: 16px; height: 16px; border: 2px solid var(--border, #333340); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: openvibe-spin .8s linear infinite; }++ .openvibe-nv-foot { padding: 8px 12px; border-top: 1px solid var(--border, #333340); font-size: 11px; color: var(--text-muted, #707080); display: flex; justify-content: space-between; align-items: center; gap: 8px; }++ .openvibe-nv-foot a { color: var(--accent-light, #60a5fa); text-decoration: none; }++ .openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }++++ /* The panel hangs under whatever navbar the site has (its top is measured when it opens) and never++ leaves the visible viewport: dvh instead of vh, room for the home indicator, list scrolls inside. */++ .openvibe-notif-panel { top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));++ max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px)); border-radius: 16px; }++ .openvibe-notif-item .title .count { display: inline-block; margin-left: 4px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); vertical-align: 1px; }++ /* Phones and narrow windows: a sheet from edge to edge under the bar, down to the bottom inset. */++ @media (max-width: 640px) {++ .openvibe-notif-panel { left: 8px; right: 8px; width: auto; bottom: calc(8px + env(safe-area-inset-bottom, 0px)); max-height: none; }++ .openvibe-notif-panel [data-act] > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } /* icon-only actions, labels stay for screen readers */++ .openvibe-notif-panel [data-act] { min-width: 36px; min-height: 36px; justify-content: center; }++ .openvibe-notif-item { padding-top: 10px; padding-bottom: 10px; }++ }++ @media (max-height: 520px) { .openvibe-notif-item .msg { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } }++ @media (max-width: 460px) {++ .openvibe-toast-container { right: 8px; left: 8px; max-width: none; }++ }++ `;++ document.head.appendChild(style);++ }++++ // ── Helpers ─────────────────────────────────────────────++ function esc(v) {++ if (v == null) return '';++ return String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');++ }++ function richOf(n) {++ const rc = n && (n.rich_content ?? n.richContent);++ if (!rc) return {};++ if (typeof rc === 'object') return rc;++ try { return JSON.parse(rc) || {}; } catch { return {}; }++ }++ function urlOf(n) { return (n && n.url) || richOf(n).url || null; }++ function isRead(n) { return !!(n && (n.is_read === 1 || n.is_read === true || n.read === true)); }++ function parseDate(v) {++ if (!v) return null;++ // SQLite "YYYY-MM-DD HH:MM:SS" is UTC without a zone marker.++ const s = String(v);++ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? s.replace(' ', 'T') + 'Z' : s);++ return isNaN(d.getTime()) ? null : d;++ }++ function timeAgo(v) {++ const d = parseDate(v); if (!d) return '';++ const diff = (Date.now() - d.getTime()) / 1000;++ if (diff < 45) return 'just now';++ if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m ago`;++ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;++ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;++ return d.toLocaleDateString();++ }++ function inboxUrl() {++ if (_config.inboxUrl) return _config.inboxUrl;++ try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }++ }++ function iconHtml(n) {++ const rich = richOf(n);++ const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);++ if (img && /^https?:\/\//.test(String(img))) return `<img src="${esc(img)}" alt="" loading="lazy" onerror="this.replaceWith(document.createTextNode('${esc(n.icon || '🔔')}'))">`;++ return esc(n.icon || '🔔');++ }++ function navigateTo(url) {++ if (!url) return;++ try {++ const u = new URL(url, window.location.href);++ window.location.href = u.toString();++ } catch { window.location.href = url; }++ }++++ // ── API ─────────────────────────────────────────────────++ function isCrossOrigin() {++ try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }++ }++ function apiFetch(path, opts = {}) {++ const headers = { ...(opts.headers || {}) };++ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;++ if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {++ headers['Content-Type'] = 'application/json';++ opts = { ...opts, body: JSON.stringify(opts.body) };++ }++ return fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: isCrossOrigin() ? 'omit' : 'include' });++ }++ async function apiJson(path, opts) {++ const res = await apiFetch(path, opts);++ let data = null; try { data = await res.json(); } catch { /* */ }++ if (!res.ok) { const e = new Error((data && data.error) || `HTTP ${res.status}`); e.status = res.status; e.body = data; throw e; }++ return data || {};++ }++ function hasSession() { return !!_config.token || !isCrossOrigin(); }++++ // ── Cross-tab sync (same origin) ────────────────────────++ function channel() {++ if (_channel !== null) return _channel || null;++ try { _channel = new BroadcastChannel('openvibe-notifications'); _channel.onmessage = (e) => onSyncMessage(e.data || {}); }++ catch { _channel = false; }++ return _channel || null;++ }++ function broadcast(msg) { try { channel()?.postMessage(msg); } catch { /* */ } }++ function onSyncMessage(msg) {++ if (msg.type === 'unread') setBadge(msg.count, { quiet: true });++ else if (msg.type === 'read') { for (const v of _views.values()) applyReadLocally(v, msg.ids || [], msg.all); }++ }++++ // ── Sound ───────────────────────────────────────────────++ function playSound(priority) {++ if (!_preferences.sound) return;++ const file = SOUNDS[priority]; if (!file) return;++ const url = `${_config.soundBase}/${file}`;++ try {++ if (!_audioCache[url]) _audioCache[url] = new Audio(url);++ const a = _audioCache[url]; a.currentTime = 0; a.volume = priority === 'critical' ? 0.8 : 0.5; a.play().catch(() => {});++ } catch { /* */ }++ }++++ // ── Toasts ──────────────────────────────────────────────++ function ensureToastContainer() {++ if (_toastContainer) return;++ _toastContainer = document.createElement('div');++ _toastContainer.className = 'openvibe-toast-container';++ document.body.appendChild(_toastContainer);++ }++ function showToast(n) {++ if (!_preferences.toasts) return;++ if (_preferences.muted_categories.includes(n.category)) return;++ ensureToastContainer();++ while (_toastContainer.children.length >= MAX_TOASTS) _toastContainer.firstChild.remove();++ const rich = richOf(n);++ const toast = document.createElement('div');++ toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;++ toast.dataset.notifId = n.id || '';++ const serviceLabel = n.service && n.service !== 'network' ? `<span class="service-badge">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';++ const actions = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input') : [];++ toast.innerHTML = `++ <div class="openvibe-toast-icon">${iconHtml(n)}</div>++ <div class="openvibe-toast-body">++ <div class="openvibe-toast-title">${esc(n.title)}${serviceLabel}</div>++ <div class="openvibe-toast-msg">${esc(n.message || '')}</div>++ ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => `<button data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}++ </div>++ <button class="openvibe-toast-close" aria-label="Dismiss">×</button>`;++ const duration = TOAST_DURATION[n.priority] ?? 5000;++ if (duration > 0) {++ const bar = document.createElement('div');++ bar.className = 'openvibe-toast-progress'; bar.style.width = '100%'; bar.style.transition = `width ${duration}ms linear`;++ toast.appendChild(bar);++ requestAnimationFrame(() => { bar.style.width = '0%'; });++ setTimeout(() => dismissToast(toast), duration);++ }++ toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });++ toast.addEventListener('click', () => {++ if (n.id) markRead([n.id]);++ dismissToast(toast);++ const u = urlOf(n); if (u) navigateTo(u);++ });++ toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {++ e.stopPropagation();++ const a = actions.find(x => String(x.id) === btn.dataset.action);++ if (a && a.url) { navigateTo(a.url); return; }++ if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);++ dismissToast(toast);++ }));++ _toastContainer.appendChild(toast);++ }++ function dismissToast(el) {++ if (!el || !el.parentNode) return;++ el.classList.add('removing');++ setTimeout(() => el.remove(), 300);++ }++++ // ── Bell / badge ────────────────────────────────────────++ function createBellEl() {++ const bell = document.createElement('div');++ bell.className = 'openvibe-bell';++ bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;++ bell.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg><span class="badge hidden">0</span>`;++ bell.addEventListener('click', () => togglePanel());++ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });++ _bellEl = bell;++ return bell;++ }++ function setBadge(count, { quiet = false } = {}) {++ const prev = _unreadCount;++ _unreadCount = Math.max(0, Number(count) || 0);++ if (_bellEl) {++ const badge = _bellEl.querySelector('.badge');++ if (_unreadCount > 0) {++ badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);++ badge.classList.remove('hidden');++ if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 600); }++ } else badge.classList.add('hidden');++ }++ for (const v of _views.values()) {++ const c = v.root.querySelector('.openvibe-nv-head .count');++ if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }++ }++ if (!quiet) broadcast({ type: 'unread', count: _unreadCount });++ }++++ // ── List views (panel + inbox share this) ───────────────++ const SVG = {++ search: '<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>',++ check: '<svg viewBox="0 0 24 24"><path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z"/></svg>',++ bellOn: '<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>',++ bellOff: '<svg viewBox="0 0 24 24"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31 0 0-.01 0-.01.01L18 14.68z"/></svg>',++ gear: '<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>',++ open: '<svg viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>',++ };++++ function buildView(root, { mode }) {++ const v = { root, mode, tab: 'all', category: '', q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };++ root.innerHTML = `++ <div class="openvibe-nv-head">++ <h3>Notifications <span class="count zero">0</span></h3>++ <div class="openvibe-nv-actions">++ <button type="button" data-act="read-all" title="Mark all as read">${SVG.check}<span>Mark all read</span></button>++ <button type="button" data-act="push" title="Desktop / mobile push alerts on this device">${SVG.bellOff}</button>++ ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open inbox">${SVG.open}</a>` : `<a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification preferences">${SVG.gear}</a>`}++ </div>++ </div>++ <div class="openvibe-nv-note" data-role="note" style="display:none"></div>++ <div class="openvibe-nv-tools">++ <div class="openvibe-nv-search">${SVG.search}<input type="search" placeholder="Search notifications…" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>++ <div class="openvibe-nv-chips">++ <button type="button" data-tab="all" class="active">All</button>++ <button type="button" data-tab="unread">Unread</button>++ <select data-role="category"><option value="">All categories</option>${Object.entries(CATEGORY_LABELS).map(([k, l]) => `<option value="${k}">${l}</option>`).join('')}</select>++ </div>++ </div>++ <div class="openvibe-nv-list" data-role="list"></div>++ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Open full inbox →</a>` : ''}</div>`;++ const list = root.querySelector('[data-role="list"]');++ const input = root.querySelector('input[type="search"]');++ let qTimer = null;++ input.addEventListener('input', () => {++ root.querySelector('.openvibe-nv-search').classList.toggle('has-q', !!input.value);++ clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);++ });++ root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });++ root.querySelectorAll('[data-tab]').forEach(btn => btn.addEventListener('click', () => {++ root.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active')); btn.classList.add('active');++ v.tab = btn.dataset.tab; reload(v);++ }));++ root.querySelector('[data-role="category"]').addEventListener('change', (e) => { v.category = e.target.value; reload(v); });++ root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());++ root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));++ // Infinite scroll: load the next page when the user nears the bottom.++ list.addEventListener('scroll', () => {++ if (v.loading || !v.hasMore) return;++ if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);++ });++ list.addEventListener('click', (e) => {++ const dismissBtn = e.target.closest('.dismiss');++ const item = e.target.closest('.openvibe-notif-item');++ if (!item) return;++ const id = item.dataset.id;++ const ids = (item.dataset.ids || id).split(',').filter(Boolean);++ if (dismissBtn) { e.stopPropagation(); ids.forEach(x => dismiss(v, x)); return; }++ const n = v.items.find(x => String(x.id) === id);++ const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });++ if (unreadIds.length) markRead(unreadIds);++ const u = urlOf(n);++ if (u) navigateTo(u);++ });++ refreshPushButton(v);++ _views.set(root, v);++ return v;++ }++++ function renderItems(v, { append = false } = {}) {++ const list = v.root.querySelector('[data-role="list"]');++ // The same event repeated back to back ("X is live!" six times) reads as one row with a count.++ const groups = [];++ for (const n of v.items) {++ const key = [n.title, n.message || '', n.service || '', n.category || ''].join('\u0001');++ const g = groups[groups.length - 1];++ if (g && g.key === key) { g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true; } else groups.push({ key, n, ids: [String(n.id)], unread: !isRead(n) });++ }++ const html = groups.map(({ n, ids, unread }) => {++ try {++ const cat = CATEGORY_LABELS[n.category] || n.category || '';++ const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';++ return `<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" title="${esc(urlOf(n) ? 'Open' : '')}">++ <span class="icon">${iconHtml(n)}</span>++ <div class="content">++ <div class="title">${esc(n.title)}${ids.length > 1 ? ` <span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>++ ${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}++ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>++ </div>++ <button type="button" class="dismiss" title="Dismiss" aria-label="Dismiss">×</button>++ </div>`;++ } catch { return ''; }++ }).join('');++ if (!append) list.innerHTML = '';++ if (!v.items.length) {++ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">${v.q ? '🔎' : '🔔'}</span>${v.q ? 'Nothing matches that search' : (v.tab === 'unread' ? 'You\'re all caught up' : 'No notifications yet')}</div>`;++ } else {++ const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();++ list.insertAdjacentHTML('beforeend', html);++ if (v.hasMore) list.insertAdjacentHTML('beforeend', `<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>`);++ const more = list.querySelector('[data-act="more"]'); if (more) more.addEventListener('click', () => loadMore(v));++ }++ const st = v.root.querySelector('[data-role="status"]');++ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';++ }++++ async function fetchPage(v, offset) {++ const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });++ if (v.tab === 'unread') params.set('unread_only', '1');++ if (v.category) params.set('category', v.category);++ if (v.q) params.set('q', v.q);++ return apiJson(`/api/notifications?${params}`);++ }++ async function reload(v) {++ const seq = ++v.seq;++ v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null;++ const list = v.root.querySelector('[data-role="list"]');++ list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';++ try {++ const data = await fetchPage(v, 0);++ if (seq !== v.seq) return;++ v.items = data.notifications || []; v.items.forEach(n => v.ids.add(String(n.id)));++ v.hasMore = !!data.has_more; v.total = data.total ?? null; v.offset = v.items.length;++ renderItems(v);++ } catch (e) {++ if (seq !== v.seq) return;++ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">⚠️</span>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</div>`;++ } finally { if (seq === v.seq) v.loading = false; }++ }++ async function loadMore(v) {++ if (v.loading || !v.hasMore) return;++ const seq = v.seq; v.loading = true;++ const more = v.root.querySelector('.openvibe-nv-more'); if (more) more.innerHTML = '<span class="spinner"></span>';++ try {++ const data = await fetchPage(v, v.offset);++ if (seq !== v.seq) return;++ const fresh = (data.notifications || []).filter(n => !v.ids.has(String(n.id)));++ fresh.forEach(n => v.ids.add(String(n.id)));++ v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;++ // Re-render only the tail to keep the scroll position.++ const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();++ if (fresh.length) renderItems({ ...v, items: fresh }, { append: true });++ else if (v.hasMore) { const list = v.root.querySelector('[data-role="list"]'); list.insertAdjacentHTML('beforeend', '<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>'); list.querySelector('[data-act="more"]').addEventListener('click', () => loadMore(v)); }++ const st = v.root.querySelector('[data-role="status"]');++ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';++ } catch { /* keep what we have */ }++ finally { v.loading = false; }++ }++ function applyReadLocally(v, ids, all) {++ const set = new Set((ids || []).map(String));++ for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;++ v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => { if (all || set.has(el.dataset.id)) el.classList.remove('unread'); });++ }++++ // ── Mutations ───────────────────────────────────────────++ async function markRead(ids) {++ ids = (ids || []).map(String).filter(Boolean);++ if (!ids.length) return;++ for (const v of _views.values()) applyReadLocally(v, ids, false);++ broadcast({ type: 'read', ids });++ try {++ const data = ids.length === 1++ ? await apiJson(`/api/notifications/${encodeURIComponent(ids[0])}/read`, { method: 'POST' })++ : await apiJson('/api/notifications/read-batch', { method: 'POST', body: { ids } });++ if (data && typeof data.unread === 'number') setBadge(data.unread, { quiet: true }), broadcast({ type: 'unread', count: data.unread });++ else pollUnread();++ } catch { pollUnread(); }++ }++ async function markAllRead() {++ for (const v of _views.values()) applyReadLocally(v, [], true);++ setBadge(0);++ broadcast({ type: 'read', all: true });++ try { await apiJson('/api/notifications/read-all', { method: 'POST', body: {} }); } catch { pollUnread(); }++ }++ async function dismiss(v, id) {++ const n = v.items.find(x => String(x.id) === String(id));++ v.items = v.items.filter(x => String(x.id) !== String(id));++ v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`)?.remove();++ if (v.total != null) v.total = Math.max(0, v.total - 1);++ if (!v.items.length) renderItems(v);++ try {++ await apiJson(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });++ if (n && !isRead(n)) setBadge(Math.max(0, _unreadCount - 1), { quiet: true });++ } catch { /* */ }++ }++++ // ── Polling / toasts ────────────────────────────────────++ let _polling = false;++ async function pollUnread() {++ if (_polling || !hasSession()) return;++ _polling = true;++ _lastPoll = Date.now();++ try {++ const data = await apiJson('/api/notifications/unread-count');++ const prev = _unreadCount;++ const count = Number(data.count) || 0;++ setBadge(count);++ if (count > prev && _lastSeenAt) {++ // Something new arrived: fetch what's newer than the last thing we saw.++ const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });++ const nd = await apiJson(`/api/notifications?${params}`);++ const fresh = (nd.notifications || []).slice().reverse();++ for (const n of fresh) { showToast(n); playSound(n.priority); }++ // Any open list should reflect the new items too.++ for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);++ }++ const newest = await newestCreatedAt();++ if (newest) _lastSeenAt = newest;++ } catch { /* offline / signed out — try again next tick */ }++ finally { _polling = false; }++ }++ async function newestCreatedAt() {++ try { const d = await apiJson('/api/notifications/newest'); const c = d.notification && d.notification.created_at; return c ? String(c) : null; } catch { return null; }++ }++ function startPolling() {++ stopPolling();++ if (!hasSession()) return;++ newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });++ _pollTimer = setInterval(() => { if (!liveOpen() || Date.now() - _lastPoll >= REALTIME_POLL) pollUnread(); }, POLL_INTERVAL);++ }++ function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }++++ // ── Realtime (notification-live.js) ─────────────────────++ function liveOpen() { return !!(_live && _live.state().state === 'open'); }++ /** One re-read for a burst of events (a go-live fan-out, a replay after a reconnect); a gap re-reads open lists too. */++ function soon(all) {++ _soonAll = _soonAll || all;++ if (_soon) return;++ _soon = setTimeout(() => {++ _soon = null;++ if (_polling) { soon(false); return; } // a poll in flight may predate the event: read again after it++ const lists = _soonAll; _soonAll = false;++ pollUnread();++ if (lists) for (const v of _views.values()) reload(v);++ }, 300);++ }++ function liveSrc() {++ if (_config.liveSrc) return _config.liveSrc;++ const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);++ return `${m ? m[1] : 'https://openvibe.network/shared/'}notification-live.js`;++ }++ let _liveLoading = null;++ function loadLive() {++ if (root.OVNotificationLive) return Promise.resolve(root.OVNotificationLive);++ return _liveLoading || (_liveLoading = new Promise((ok) => {++ const sc = document.createElement('script');++ sc.src = liveSrc(); sc.async = true;++ sc.onload = () => ok(root.OVNotificationLive || null);++ sc.onerror = () => { _liveLoading = null; ok(null); };++ document.head.appendChild(sc);++ }));++ }++ function startRealtime() {++ if (!_config.realtime || !hasSession() || typeof root.EventSource !== 'function') return;++ if (_live) { _live.restart(); return; }++ loadLive().then((L) => {++ if (!L || _live || !_config.realtime || !hasSession()) return;++ _live = L.create({++ ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,++ token: () => _config.token,++ credentials: isCrossOrigin() ? 'omit' : 'include',++ onNotification: () => soon(false),++ onGap: () => soon(true),++ });++ _live.start();++ });++ }++ function stopRealtime() { if (_live) _live.stop(); }++ document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });++++ // ── Panel ───────────────────────────────────────────────++ function createPanel() {++ const panel = document.createElement('div');++ panel.className = 'openvibe-notif-panel';++ document.body.appendChild(panel);++ buildView(panel, { mode: 'panel' });++ document.addEventListener('click', (e) => {++ if (panel.classList.contains('open') && !panel.contains(e.target) && !(_bellEl && _bellEl.contains(e.target))) closePanel();++ });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel.classList.contains('open')) closePanel(); });++ _panelEl = panel;++ return panel;++ }++ function closePanel() { _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); }++ function togglePanel() {++ if (!_panelEl) createPanel();++ const opening = !_panelEl.classList.contains('open');++ if (opening && window.OpenVibePanels && !_panelEl.__registered) { _panelEl.__registered = true; window.OpenVibePanels.register({ el: _panelEl, openClass: 'open', id: 'notifications', close: () => { if (_panelEl.classList.contains('open')) togglePanel(); } }); window.OpenVibePanels.closeAll(_panelEl); }++ if (opening) {++ // Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).++ const bar = _bellEl && _bellEl.closest('nav, header, .navbar');++ const bottom = bar ? bar.getBoundingClientRect().bottom : (_bellEl ? _bellEl.getBoundingClientRect().bottom : 56);++ _panelEl.style.setProperty('--ovn-top', Math.max(8, Math.round(bottom) + 6) + 'px');++ if (!_panelEl.__esc) { _panelEl.__esc = (e) => { if (e.key === 'Escape' && _panelEl.classList.contains('open')) togglePanel(); }; document.addEventListener('keydown', _panelEl.__esc); }++ }++ _panelEl.classList.toggle('open', opening);++ _bellEl?.classList.toggle('open', opening);++ if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }++ }++++ // ── Web Push ────────────────────────────────────────────++ function pushSupported() { return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window && window.isSecureContext; }++ function urlBase64ToUint8Array(b64) {++ const padding = '='.repeat((4 - (b64.length % 4)) % 4);++ const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');++ const raw = window.atob(base64); const out = new Uint8Array(raw.length);++ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);++ return out;++ }++ async function currentSubscription() {++ if (!pushSupported()) return null;++ try { const reg = await navigator.serviceWorker.getRegistration(_config.swPath); return reg ? await reg.pushManager.getSubscription() : null; } catch { return null; }++ }++ async function pushStatus() {++ const supported = pushSupported();++ const sub = supported ? await currentSubscription() : null;++ return { supported, permission: supported ? Notification.permission : 'unsupported', subscribed: !!sub };++ }++ async function enablePush() {++ if (!pushSupported()) throw new Error('This browser does not support push notifications');++ if (!hasSession()) throw new Error('Sign in first');++ const perm = await Notification.requestPermission();++ if (perm !== 'granted') throw new Error(perm === 'denied' ? 'Notifications are blocked for this site in your browser settings' : 'Permission not granted');++ const reg = await navigator.serviceWorker.register(_config.swPath, { scope: '/' });++ await navigator.serviceWorker.ready;++ const { publicKey } = await apiJson('/api/push/vapid-key');++ if (!publicKey) throw new Error('Push is not configured on the server');++ let sub = await reg.pushManager.getSubscription();++ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });++ await apiJson('/api/push/subscribe', { method: 'POST', body: { subscription: { ...sub.toJSON(), userAgent: navigator.userAgent.slice(0, 200) } } });++ try { localStorage.setItem('ov_push_enabled', '1'); } catch { /* */ }++ return true;++ }++ async function disablePush() {++ const sub = await currentSubscription();++ if (sub) {++ try { await apiJson('/api/push/unsubscribe', { method: 'POST', body: { endpoint: sub.endpoint } }); } catch { /* */ }++ try { await sub.unsubscribe(); } catch { /* */ }++ }++ try { localStorage.removeItem('ov_push_enabled'); } catch { /* */ }++ return true;++ }++ async function refreshPushButton(v) {++ const btn = v && v.root.querySelector('[data-act="push"]');++ if (!btn) return;++ const st = await pushStatus();++ if (!st.supported) { btn.style.display = 'none'; return; }++ btn.style.display = '';++ btn.classList.toggle('on', st.subscribed);++ btn.innerHTML = st.subscribed ? `${SVG.bellOn}<span>Push on</span>` : `${SVG.bellOff}<span>Enable push</span>`;++ btn.title = st.subscribed ? 'Push alerts are on for this device — click to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get desktop / mobile alerts on this device even when the tab is closed');++ btn.disabled = st.permission === 'denied' && !st.subscribed;++ }++ async function togglePush(v) {++ const note = v.root.querySelector('[data-role="note"]');++ const say = (msg, ms = 4000) => { if (!note) return; note.textContent = msg; note.style.display = ''; clearTimeout(note._t); note._t = setTimeout(() => { note.style.display = 'none'; }, ms); };++ try {++ const st = await pushStatus();++ if (st.subscribed) { await disablePush(); say('Push alerts turned off for this device.'); }++ else { await enablePush(); say('Push alerts enabled — you\'ll get alerts here even when this site is closed.'); }++ } catch (e) { say(e.message || 'Could not change push setting', 6000); }++ for (const view of _views.values()) refreshPushButton(view);++ }++++ // ── Public API ──────────────────────────────────────────++ const OpenVibeNotifications = {++ init(opts = {}) {++ Object.assign(_config, opts);++ injectStyles();++ if (opts.preferences) Object.assign(_preferences, opts.preferences);++ channel();++ startPolling();++ startRealtime();++ // If the user enabled push on this device before, make sure the worker is registered.++ try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }++ },++ destroy() {++ stopPolling();++ stopRealtime();++ _toastContainer?.remove(); _panelEl?.remove();++ _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;++ },++ /** Create and inject the bell icon. Returns the DOM element. */++ createBell(container) {++ injectStyles();++ const bell = createBellEl();++ if (container) container.appendChild(bell);++ pollUnread();++ return bell;++ },++ /** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */++ renderInbox(container, opts = {}) {++ injectStyles();++ if (!container) return null;++ container.classList.add('openvibe-notif-inbox');++ const v = buildView(container, { mode: 'inbox' });++ if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === opts.tab)); }++ reload(v);++ return v;++ },++ /** Programmatically show a toast (real-time events). Marks nothing on the server. */++ push(notification) {++ showToast(notification);++ playSound(notification.priority);++ if (!notification.id) return;++ setBadge(_unreadCount + 1);++ },++ /** Re-fetch the badge and any open lists (call after an in-page action). */++ refresh() { pollUnread(); for (const v of _views.values()) reload(v); },++ markRead(ids) { return markRead(Array.isArray(ids) ? ids : [ids]); },++ markAllRead,++ setPreferences(prefs) { Object.assign(_preferences, prefs); },++ setToken(token) {++ _config.token = token;++ if (token || !isCrossOrigin()) { startPolling(); startRealtime(); } else { stopPolling(); stopRealtime(); setBadge(0, { quiet: true }); }++ },++ /** The realtime feed's state ({ state: 'off' | 'ticket' | 'connecting' | 'open' | 'backoff' | 'hidden' | 'blocked' | 'failed' | 'signed-out' | 'unavailable', … }), or null when off. */++ realtimeState() { return _live ? _live.state() : null; },++ togglePanel, closePanel,++ enablePush, disablePush, pushStatus,++ get unreadCount() { return _unreadCount; },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;++ else root.OpenVibeNotifications = OpenVibeNotifications;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,41 @@++/* OpenVibe — shared Web Push service worker.++ * Served by every OpenVibe site at /openvibe-sw.js (must be same-origin, scope "/").++ * Payload (from openvibe.network push-service): { title, body, icon, url, tag } */++self.addEventListener('install', () => self.skipWaiting());++self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));++++self.addEventListener('push', (event) => {++ let data = {};++ try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'OpenVibe', body: event.data ? event.data.text() : '' }; }++ const title = data.title || 'OpenVibe';++ const options = {++ body: data.body || '',++ icon: data.icon || 'https://openvibe.network/assets/logo-192.png',++ badge: data.badge || 'https://openvibe.network/assets/logo-72.png',++ tag: data.tag || 'openvibe',++ renotify: !!data.renotify,++ data: { url: data.url || 'https://openvibe.network/notifications', id: data.id || null },++ requireInteraction: false,++ };++ event.waitUntil(self.registration.showNotification(title, options));++});++++self.addEventListener('notificationclick', (event) => {++ event.notification.close();++ const url = (event.notification.data && event.notification.data.url) || 'https://openvibe.network/notifications';++ event.waitUntil((async () => {++ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });++ let target = null;++ try { target = new URL(url); } catch { /* */ }++ for (const c of all) {++ try {++ if (target && new URL(c.url).origin === target.origin && 'focus' in c) {++ await c.focus();++ if ('navigate' in c) { try { await c.navigate(url); } catch { /* cross-origin navigate blocked */ } }++ return;++ }++ } catch { /* */ }++ }++ await self.clients.openWindow(url);++ })());++});nnew file mode 100644@@@ -0,0 +1,272 @@++/**++ * 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') ,++ };++ // [dx, dy] in glyph units that move each glyph's drawn bounds onto the centre of the ring. Generated.++ const OFFSETS = {"ov":[0,-0.5],"network":[0,0.5],"tools":[1.26,0.24],"games":[0,-1.52],"community":[0,-0.45],"chat":[0,-1],"wiki":[0,-0.25],"reviews":[0,0.6],"tips":[0,-0.76],"vip":[0,-0.75],"trade":[0,-1],"deals":[0,-1],"coupons":[0,-0.5],"stream":[0,0.95],"download":[0,0.25],"audio":[0.7,-0.35],"pdf":[-0.25,0],"docs":[-0.25,0],"logo":[-1.25,-0.5],"ssl":[0,-0.25],"whois":[-0.5,-0.5],"search":[-0.5,-0.5],"food":[0.25,0],"account":[0,-0.4],"bell":[0,-1.37],"error":[-0.01,0.25]};++ 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-box:view-box;transform-origin:12px 12px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}++.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:transform .25s cubic-bezier(.2,1.4,.3,1),fill .2s}++.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}++.ov-icon[style*="--ovi-size:2"] .ovi-glyph,.ov-icon[data-size^="2"] .ovi-glyph{stroke-width:2.3}++.ov-icon[style*="--ovi-size:2"] .ovi-ring,.ov-icon[data-size^="2"] .ovi-ring{stroke-width:3;opacity:.34}++.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}++.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}++.ov-icon[data-fx=none] .ovi-comet{display:none}++.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}}`;++++ // BEGIN generated solid glyphs (scripts/build-nav-icons.py)++ // Font Awesome Free 6 solid glyphs (CC BY 4.0) for the ring icons; viewBox 0 0 <w> 512.++ const SOLID = {++ network: [512, 'M418 169Q445 162 462 141Q479 120 480 91Q479 57 457 34Q434 12 400 11Q367 12 344 34Q322 55 320 88L136 162Q114 140 80 139Q46 140 23 162Q1 185 0 219Q1 253 23 276Q46 298 80 299Q98 299 114 291L260 419Q256 430 256 443Q257 477 279 500Q302 522 336 523Q370 522 393 500Q415 477 416 443Q414 400 381 377L418 169ZM156 243Q160 233 160 222L344 148Q349 153 355 158L318 365Q309 367 302 371L156 243Z'],++ live: [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],++ tools: [512, 'M79 16Q62 5 47 18L7 58Q-6 73 5 90L85 194Q92 203 104 203H158L267 312Q256 334 260 359Q263 383 281 402L393 514Q403 523 416 523Q429 523 439 514L503 450Q512 440 512 427Q512 414 503 404L391 292Q372 274 348 271Q323 267 301 278L192 169V115Q192 103 183 96L79 16ZM20 407Q0 427 0 455Q1 484 20 503Q39 522 68 523Q96 523 116 503L234 385Q222 353 230 320L168 258L20 407ZM512 155Q512 139 509 125Q506 116 499 114Q491 113 485 119L421 182Q416 187 409 187H352Q337 186 336 171V114Q336 107 341 102L405 38Q411 32 409 24Q407 17 399 14Q384 11 368 11Q307 13 266 53Q226 94 224 155V156L309 241Q337 234 364 241Q392 249 413 270L429 286Q466 268 489 233Q511 199 512 155ZM56 443Q58 421 80 419Q102 421 104 443Q102 465 80 467Q58 465 56 443Z'],++ media: [640, 'M256 11H576H256H576Q603 12 621 30Q639 48 640 75V299Q639 326 621 344Q603 362 576 363H256Q229 362 211 344Q193 326 192 299V75Q193 48 211 30Q229 12 256 11ZM476 118Q469 107 456 107Q443 107 436 118L380 202L363 180Q355 171 344 171Q333 171 325 180L261 260Q252 272 258 285Q265 298 280 299H360H552Q566 299 573 286Q579 274 572 262L476 118ZM336 107Q336 93 327 84Q318 75 304 75Q290 75 281 84Q272 93 272 107Q272 121 281 130Q290 139 304 139Q318 139 327 130Q336 121 336 107ZM64 139H160H64H160V395V427Q160 441 169 450Q178 459 192 459H320Q334 459 343 450Q352 441 352 427V395H512V459Q511 486 493 504Q475 522 448 523H64Q37 522 19 504Q1 486 0 459V203Q1 176 19 158Q37 140 64 139ZM72 203Q57 204 56 219V235Q57 250 72 251H88Q103 250 104 235V219Q103 204 88 203H72ZM72 307Q57 308 56 323V339Q57 354 72 355H88Q103 354 104 339V323Q103 308 88 307H72ZM72 411Q57 412 56 427V443Q57 458 72 459H88Q103 458 104 443V427Q103 412 88 411H72ZM408 427V443V427V443Q409 458 424 459H440Q455 458 456 443V427Q455 412 440 411H424Q409 412 408 427Z'],++ games: [640, 'M192 75Q110 77 56 131Q2 185 0 267Q2 349 56 403Q110 457 192 459H448Q530 457 584 403Q638 349 640 267Q638 185 584 131Q530 77 448 75H192ZM496 179Q519 180 531 199Q541 219 531 239Q519 258 496 259Q473 258 461 239Q451 219 461 199Q473 180 496 179ZM392 315Q393 292 412 280Q432 270 452 280Q471 292 472 315Q471 338 452 350Q432 360 412 350Q393 338 392 315ZM168 211Q170 189 192 187Q214 189 216 211V243H248Q270 245 272 267Q270 289 248 291H216V323Q214 345 192 347Q170 345 168 323V291H136Q114 289 112 267Q114 245 136 243H168V211Z'],++ community: [640, 'M72 99Q73 67 100 51Q128 35 156 51Q183 67 184 99Q183 131 156 147Q128 163 100 147Q73 131 72 99ZM64 257Q49 274 48 299Q49 324 64 341V257ZM208 207Q162 249 160 315Q161 367 192 406V427Q192 441 183 450Q174 459 160 459H96Q82 459 73 450Q64 441 64 427V400Q35 386 18 360Q0 333 0 299Q1 251 33 220Q64 188 112 187H144Q181 188 208 207ZM448 427V406V427V406Q479 367 480 315Q478 249 432 207Q459 188 496 187H528Q576 188 607 220Q639 251 640 299Q640 333 622 360Q605 386 576 400V427Q576 441 567 450Q558 459 544 459H480Q466 459 457 450Q448 441 448 427ZM456 99Q457 67 484 51Q512 35 540 51Q567 67 568 99Q567 131 540 147Q512 163 484 147Q457 131 456 99ZM576 257V341V257V341Q592 324 592 299Q592 274 576 257ZM320 43Q356 44 375 75Q393 107 375 139Q356 170 320 171Q284 170 265 139Q247 107 265 75Q284 44 320 43ZM240 315Q241 340 256 357V273Q241 290 240 315ZM384 273V357V273V357Q400 340 400 315Q400 290 384 273ZM448 315Q448 349 430 376Q413 402 384 416V459Q384 473 375 482Q366 491 352 491H288Q274 491 265 482Q256 473 256 459V416Q227 402 210 376Q192 349 192 315Q193 267 225 236Q256 204 304 203H336Q384 204 415 236Q447 267 448 315Z'],++ chat: [640, 'M208 363Q296 361 355 311Q414 262 416 187Q414 112 355 63Q296 13 208 11Q120 13 61 63Q2 112 0 187Q1 246 40 290Q34 305 25 315Q18 324 12 329Q9 332 8 333Q7 334 7 334H6Q-2 341 1 352Q5 362 16 363Q50 362 78 351Q92 345 103 339Q150 362 208 363ZM448 187Q445 273 384 329Q322 385 232 394Q251 451 305 486Q359 522 432 523Q490 522 537 499Q548 505 562 511Q590 522 624 523Q635 522 639 512Q642 501 633 494Q633 494 633 493Q632 493 632 493Q631 492 628 489Q622 484 615 475Q606 464 600 450Q639 406 640 347Q638 276 584 227Q530 178 447 171Q448 179 448 187Z'],++ codes: [640, 'M393 12Q380 9 368 15Q357 21 353 34L225 482Q222 495 228 507Q234 518 247 522Q260 525 272 519Q283 513 287 500L415 52Q418 39 412 27Q406 16 393 12ZM473 132Q464 142 464 155Q464 168 473 178L563 267L473 356Q464 366 464 379Q464 392 473 402Q483 411 496 411Q509 411 519 402L631 290Q640 280 640 267Q640 254 631 244L519 132Q509 123 496 123Q483 123 473 132ZM167 132Q157 123 144 123Q131 123 121 132L9 244Q0 254 0 267Q0 280 9 290L121 402Q131 411 144 411Q157 411 167 402Q176 392 176 379Q176 366 167 356L77 267L167 178Q176 168 176 155Q176 142 167 132Z'],++ code: [640, 'M393 12Q380 9 368 15Q357 21 353 34L225 482Q222 495 228 507Q234 518 247 522Q260 525 272 519Q283 513 287 500L415 52Q418 39 412 27Q406 16 393 12ZM473 132Q464 142 464 155Q464 168 473 178L563 267L473 356Q464 366 464 379Q464 392 473 402Q483 411 496 411Q509 411 519 402L631 290Q640 280 640 267Q640 254 631 244L519 132Q509 123 496 123Q483 123 473 132ZM167 132Q157 123 144 123Q131 123 121 132L9 244Q0 254 0 267Q0 280 9 290L121 402Q131 411 144 411Q157 411 167 402Q176 392 176 379Q176 366 167 356L77 267L167 178Q176 168 176 155Q176 142 167 132Z'],++ blog: [512, 'M368 29 313 85 368 29 313 85 438 210 494 155Q510 137 510 115Q510 93 494 75L448 29Q430 13 408 13Q386 13 368 29ZM288 106 279 108 288 106 279 108 135 152Q104 162 92 193L4 457Q-2 475 11 489L165 336Q160 326 160 315Q161 295 174 281Q188 268 208 267Q228 268 242 281Q255 295 256 315Q255 335 242 349Q228 362 208 363Q197 363 187 358L34 512Q48 525 66 519L330 431Q361 419 371 388L415 244L417 235L288 106Z'],++ wiki: [448, 'M96 11Q55 12 28 39Q1 66 0 107V427Q1 468 28 495Q55 522 96 523H384H416Q430 523 439 514Q448 505 448 491Q448 477 439 468Q430 459 416 459V395Q430 395 439 386Q448 377 448 363V43Q448 29 439 20Q430 11 416 11H384ZM96 395H352H96H352V459H96Q82 459 73 450Q64 441 64 427Q64 413 73 404Q82 395 96 395ZM128 155Q129 140 144 139H336Q351 140 352 155Q351 170 336 171H144Q129 170 128 155ZM144 203H336H144H336Q351 204 352 219Q351 234 336 235H144Q129 234 128 219Q129 204 144 203Z'],++ news: [512, 'M96 107Q97 80 115 62Q133 44 160 43H448Q475 44 493 62Q511 80 512 107V427Q511 454 493 472Q475 490 448 491H80Q46 490 23 468Q1 445 0 411V139Q0 125 9 116Q18 107 32 107Q46 107 55 116Q64 125 64 139V411Q65 426 80 427Q95 426 96 411V107ZM160 131V211V131V211Q162 233 184 235H296Q318 233 320 211V131Q318 109 296 107H184Q162 109 160 131ZM368 123Q369 138 384 139H432Q447 138 448 123Q447 108 432 107H384Q369 108 368 123ZM368 219Q369 234 384 235H432Q447 234 448 219Q447 204 432 203H384Q369 204 368 219ZM160 315Q161 330 176 331H432Q447 330 448 315Q447 300 432 299H176Q161 300 160 315ZM160 411Q161 426 176 427H432Q447 426 448 411Q447 396 432 395H176Q161 396 160 411Z'],++ reviews: [576, 'M317 29Q308 12 288 11Q269 12 259 29L195 161L51 183Q32 186 26 204Q20 223 34 237L138 340L113 486Q111 505 126 517Q142 528 160 519L288 451L417 519Q434 528 450 517Q466 505 463 486L439 340L543 237Q556 223 551 204Q544 186 525 183L381 161L317 29Z'],++ tips: [512, 'M512 91Q511 118 474 139Q428 164 351 170Q346 167 340 165Q279 140 192 139Q180 139 168 140L166 139Q129 118 128 91Q130 57 184 34Q238 12 320 11Q402 12 456 34Q510 57 512 91ZM161 172Q176 171 192 171Q239 171 278 180Q318 188 345 202Q383 223 384 251Q384 257 382 263Q374 283 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q346 299 346 299Q346 299 346 299Q319 314 279 322Q240 331 192 331Q100 330 44 302Q41 300 38 299Q1 278 0 251Q1 224 36 204Q71 184 128 176Q144 173 161 172ZM416 251Q414 218 392 198Q435 191 468 177Q493 167 512 152V187Q511 217 468 238Q446 249 416 256Q416 256 416 256Q416 254 416 251ZM384 347Q383 374 346 395Q344 396 342 397Q341 397 340 398Q284 426 192 427Q144 427 105 418Q65 410 38 395Q1 374 0 347V312Q19 327 44 337Q105 362 192 363Q279 362 340 337Q352 332 363 326Q372 321 380 315Q381 314 383 313Q383 312 384 312V315V321V347ZM416 347V315V347V315V289Q445 283 468 273Q493 263 512 248V283Q512 299 497 314Q471 339 416 352Q416 350 416 347ZM192 459Q279 458 340 433Q365 423 384 408V443Q382 477 328 500Q274 522 192 523Q110 522 56 500Q2 477 0 443V408Q19 423 44 433Q105 458 192 459Z'],++ vip: [576, 'M309 117Q327 105 328 83Q328 66 316 55Q305 43 288 43Q271 43 260 55Q248 66 248 83Q249 106 267 117L210 232Q202 245 188 249Q174 252 161 242L72 171Q80 161 80 147Q80 130 68 119Q57 107 40 107Q23 107 12 119Q0 130 0 147Q0 164 12 175Q23 187 40 187Q40 187 40 187Q41 187 41 187L86 438Q91 462 108 476Q126 491 149 491H427Q450 491 468 476Q485 462 490 438L535 187Q535 187 536 187Q536 187 536 187Q553 187 564 175Q576 164 576 147Q576 130 564 119Q553 107 536 107Q519 107 508 119Q496 130 496 147Q496 161 504 171L415 242Q402 252 388 249Q374 245 366 232L309 117Z'],++ trade: [512, 'M64 75Q64 61 55 52Q46 43 32 43Q18 43 9 52Q0 61 0 75V411Q1 445 23 468Q46 490 80 491H480Q494 491 503 482Q512 473 512 459Q512 445 503 436Q494 427 480 427H80Q65 426 64 411V75ZM471 162Q480 152 480 139Q480 126 471 116Q461 107 448 107Q435 107 425 116L320 222L263 164Q253 155 240 155Q227 155 217 164L105 276Q96 286 96 299Q96 312 105 322Q115 331 128 331Q141 331 151 322L240 232L297 290Q307 299 320 299Q333 299 343 290L471 162Z'],++ host: [512, 'M64 43Q37 44 19 62Q1 80 0 107V171Q1 198 19 216Q37 234 64 235H448Q475 234 493 216Q511 198 512 171V107Q511 80 493 62Q475 44 448 43H64ZM344 115Q366 117 368 139Q366 161 344 163Q322 161 320 139Q322 117 344 115ZM392 139Q394 117 416 115Q438 117 440 139Q438 161 416 163Q394 161 392 139ZM64 299Q37 300 19 318Q1 336 0 363V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V363Q511 336 493 318Q475 300 448 299H64ZM344 371Q366 373 368 395Q366 417 344 419Q322 417 320 395Q322 373 344 371ZM400 395Q402 373 424 371Q446 373 448 395Q446 417 424 419Q402 417 400 395Z'],++ deals: [448, 'M0 91V241V91V241Q0 267 19 286L195 462Q214 481 240 481Q265 481 285 462L419 328Q437 309 437 283Q437 258 419 238L243 62Q224 43 197 43H48Q28 44 14 57Q1 71 0 91ZM112 123Q126 123 135 132Q144 141 144 155Q144 169 135 178Q126 187 112 187Q98 187 89 178Q80 169 80 155Q80 141 89 132Q98 123 112 123Z'],++ coupons: [576, 'M64 75Q37 76 19 94Q1 112 0 139V203Q2 217 16 222Q46 233 48 267Q46 301 16 312Q2 317 0 331V395Q1 422 19 440Q37 458 64 459H512Q539 458 557 440Q575 422 576 395V331Q574 317 560 312Q530 301 528 267Q530 233 560 222Q574 217 576 203V139Q575 112 557 94Q539 76 512 75H64ZM128 187V347V187V347Q129 362 144 363H432Q447 362 448 347V187Q447 172 432 171H144Q129 172 128 187ZM96 171Q96 157 105 148Q114 139 128 139H448Q462 139 471 148Q480 157 480 171V363Q480 377 471 386Q462 395 448 395H128Q114 395 105 386Q96 377 96 363V171Z'],++ stream: [512, 'M192 43Q192 29 201 20Q210 11 224 11Q304 12 369 50Q434 89 473 154Q511 219 512 299Q512 313 503 322Q494 331 480 331Q466 331 457 322Q448 313 448 299Q447 236 417 186Q388 135 337 106Q287 76 224 75Q210 75 201 66Q192 57 192 43ZM61 232 165 336 61 232 165 336 193 307Q192 303 192 299Q192 285 201 276Q210 267 224 267Q238 267 247 276Q256 285 256 299Q256 313 247 322Q238 331 224 331Q220 331 216 330L187 358L291 462Q302 474 300 488Q298 502 284 509Q249 523 208 523Q120 521 61 462Q2 403 0 315Q0 274 14 239Q21 225 35 223Q49 221 61 232ZM224 107Q306 109 360 163Q414 217 416 299Q416 313 407 322Q398 331 384 331Q370 331 361 322Q352 313 352 299Q351 245 315 208Q278 172 224 171Q210 171 201 162Q192 153 192 139Q192 125 201 116Q210 107 224 107Z'],++ video: [576, 'M0 139Q1 112 19 94Q37 76 64 75H320Q347 76 365 94Q383 112 384 139V395Q383 422 365 440Q347 458 320 459H64Q37 458 19 440Q1 422 0 395V139ZM559 111Q575 120 576 139V395Q575 414 559 423Q542 432 526 422L430 358L416 348V331V203V186L430 176L526 112Q542 103 559 111Z'],++ download: [512, 'M288 43Q288 29 279 20Q270 11 256 11Q242 11 233 20Q224 29 224 43V286L151 212Q141 203 128 203Q115 203 105 212Q96 222 96 235Q96 248 105 258L233 386Q243 395 256 395Q269 395 279 386L407 258Q416 248 416 235Q416 222 407 212Q397 203 384 203Q371 203 361 212L288 286V43ZM64 363Q37 364 19 382Q1 400 0 427V459Q1 486 19 504Q37 522 64 523H448Q475 522 493 504Q511 486 512 459V427Q511 400 493 382Q475 364 448 363H347L301 408Q281 427 256 427Q230 427 211 408L166 363H64ZM432 419Q454 421 456 443Q454 465 432 467Q410 465 408 443Q410 421 432 419Z'],++ upload: [512, 'M288 120V363V120V363Q288 377 279 386Q270 395 256 395Q242 395 233 386Q224 377 224 363V120L151 194Q141 203 128 203Q115 203 105 194Q96 184 96 171Q96 158 105 148L233 20Q243 11 256 11Q269 11 279 20L407 148Q416 158 416 171Q416 184 407 194Q397 203 384 203Q371 203 361 194L288 120ZM64 363H192H64H192Q193 390 211 408Q229 426 256 427Q283 426 301 408Q319 390 320 363H448Q475 364 493 382Q511 400 512 427V459Q511 486 493 504Q475 522 448 523H64Q37 522 19 504Q1 486 0 459V427Q1 400 19 382Q37 364 64 363ZM432 467Q454 465 456 443Q454 421 432 419Q410 421 408 443Q410 465 432 467Z'],++ image: [512, 'M0 107Q1 80 19 62Q37 44 64 43H448Q475 44 493 62Q511 80 512 107V427Q511 454 493 472Q475 490 448 491H64Q37 490 19 472Q1 454 0 427V107ZM324 214Q316 203 304 203Q291 203 284 214L197 341L171 308Q163 299 152 299Q141 299 133 308L69 388Q61 400 66 413Q73 426 88 427H184H424Q438 427 445 414Q451 401 444 390L324 214ZM112 203Q139 202 154 179Q166 155 154 131Q139 108 112 107Q85 108 70 131Q58 155 70 179Q85 202 112 203Z'],++ audio: [512, 'M499 17Q512 27 512 43V115V379Q511 413 484 436Q457 458 416 459Q375 458 348 436Q321 413 320 379Q321 345 348 322Q375 300 416 299Q433 299 448 304V158L192 235V443Q191 477 164 500Q137 522 96 523Q55 522 28 500Q1 477 0 443Q1 409 28 386Q55 364 96 363Q113 363 128 368V211V139Q129 116 151 108L471 12Q486 8 499 17Z'],++ pdf: [512, 'M0 75Q1 48 19 30Q37 12 64 11H224V139Q224 153 233 162Q242 171 256 171H384V315H176Q149 316 131 334Q113 352 112 379V523H64Q37 522 19 504Q1 486 0 459V75ZM384 139H256H384H256V11L384 139ZM176 363H208H176H208Q232 364 248 379Q263 395 264 419Q263 443 248 459Q232 474 208 475H192V507Q191 522 176 523Q161 522 160 507V459V379Q161 364 176 363ZM208 443Q230 441 232 419Q230 397 208 395H192V443H208ZM304 363H336H304H336Q356 364 370 377Q383 391 384 411V475Q383 495 370 509Q356 522 336 523H304Q289 522 288 507V379Q289 364 304 363ZM336 491Q351 490 352 475V411Q351 396 336 395H320V491H336ZM416 379Q417 364 432 363H480Q495 364 496 379Q495 394 480 395H448V427H480Q495 428 496 443Q495 458 480 459H448V507Q447 522 432 523Q417 522 416 507V443V379Z'],++ docs: [384, 'M64 11Q37 12 19 30Q1 48 0 75V459Q1 486 19 504Q37 522 64 523H320Q347 522 365 504Q383 486 384 459V171H256Q242 171 233 162Q224 153 224 139V11H64ZM256 11V139V11V139H384L256 11ZM112 267H272H112H272Q287 268 288 283Q287 298 272 299H112Q97 298 96 283Q97 268 112 267ZM112 331H272H112H272Q287 332 288 347Q287 362 272 363H112Q97 362 96 347Q97 332 112 331ZM112 395H272H112H272Q287 396 288 411Q287 426 272 427H112Q97 426 96 411Q97 396 112 395Z'],++ text: [448, 'M254 64Q245 44 224 43Q203 44 194 64L58 427H32Q18 427 9 436Q0 445 0 459Q0 473 9 482Q18 491 32 491H128Q142 491 151 482Q160 473 160 459Q160 445 151 436Q142 427 128 427H126L144 379H304L322 427H320Q306 427 297 436Q288 445 288 459Q288 473 297 482Q306 491 320 491H416Q430 491 439 482Q448 473 448 459Q448 445 439 436Q430 427 416 427H390L254 64ZM280 315H168H280H168L224 166L280 315Z'],++ dns: [512, 'M352 267Q352 300 349 331H163Q160 300 160 267Q160 234 163 203H349Q352 234 352 267ZM381 203H504H381H504Q512 234 512 267Q512 300 504 331H381Q384 300 384 267Q384 234 381 203ZM493 171H377H493H377Q361 73 321 19Q381 36 426 75Q470 115 493 171ZM344 171H168H344H168Q177 116 195 76Q211 41 228 25Q245 10 256 11Q267 10 284 25Q301 41 317 76Q335 116 344 171ZM135 171H19H135H19Q42 115 86 75Q131 36 191 19Q151 73 135 171ZM8 203H131H8H131Q128 234 128 267Q128 300 131 331H8Q0 300 0 267Q0 234 8 203ZM195 458Q177 418 168 363H344Q335 418 317 458Q301 493 284 509Q267 524 256 523Q245 524 228 509Q211 493 195 458ZM135 363Q151 461 191 515Q131 498 86 459Q42 419 19 363H135ZM493 363Q470 419 426 459Q381 498 322 515Q361 461 377 363H493Z'],++ ip: [384, 'M216 510Q243 477 282 421Q321 366 352 307Q382 248 384 203Q382 121 328 67Q274 13 192 11Q110 13 56 67Q2 121 0 203Q2 248 32 307Q63 366 102 421Q141 477 168 510Q178 522 192 522Q206 522 216 510ZM192 139Q228 140 247 171Q265 203 247 235Q228 266 192 267Q156 266 137 235Q119 203 137 171Q156 140 192 139Z'],++ ssl: [448, 'M144 155V203V155V203H304V155Q303 121 281 98Q258 76 224 75Q190 76 167 98Q145 121 144 155ZM80 203V155V203V155Q82 94 122 53Q163 13 224 11Q285 13 326 53Q366 94 368 155V203H384Q411 204 429 222Q447 240 448 267V459Q447 486 429 504Q411 522 384 523H64Q37 522 19 504Q1 486 0 459V267Q1 240 19 222Q37 204 64 203H80Z'],++ ping: [640, 'M128 75Q128 61 137 52Q146 43 160 43H320Q334 43 343 52Q352 61 352 75V427H448V267Q448 253 457 244Q466 235 480 235H608Q622 235 631 244Q640 253 640 267Q640 281 631 290Q622 299 608 299H512V459Q512 473 503 482Q494 491 480 491H320Q306 491 297 482Q288 473 288 459V107H192V267Q192 281 183 290Q174 299 160 299H32Q18 299 9 290Q0 281 0 267Q0 253 9 244Q18 235 32 235H128V75Z'],++ whois: [512, 'M416 219Q415 289 376 342L503 468Q512 478 512 491Q512 504 503 514Q493 523 480 523Q467 523 457 514L331 387Q278 426 208 427Q120 425 61 366Q2 307 0 219Q2 131 61 72Q120 13 208 11Q296 13 355 72Q414 131 416 219ZM208 363Q247 363 280 344Q313 325 333 291Q352 257 352 219Q352 181 333 147Q313 113 280 94Q247 75 208 75Q169 75 136 94Q103 113 83 147Q64 181 64 219Q64 257 83 291Q103 325 136 344Q169 363 208 363Z'],++ search: [512, 'M416 219Q415 289 376 342L503 468Q512 478 512 491Q512 504 503 514Q493 523 480 523Q467 523 457 514L331 387Q278 426 208 427Q120 425 61 366Q2 307 0 219Q2 131 61 72Q120 13 208 11Q296 13 355 72Q414 131 416 219ZM208 363Q247 363 280 344Q313 325 333 291Q352 257 352 219Q352 181 333 147Q313 113 280 94Q247 75 208 75Q169 75 136 94Q103 113 83 147Q64 181 64 219Q64 257 83 291Q103 325 136 344Q169 363 208 363Z'],++ map: [576, 'M384 487 192 432 384 487 192 432V47L384 102V487ZM416 486V99V486V99L543 49Q556 44 565 51Q575 58 576 71V406Q575 421 561 428L416 486ZM15 106 160 48 15 106 160 48V435L33 486Q20 490 11 483Q1 476 0 463V128Q1 113 15 106Z'],++ food: [448, 'M416 11Q406 10 376 25Q345 39 318 77Q290 116 288 187V299Q289 326 307 344Q325 362 352 363H384V491Q384 505 393 514Q402 523 416 523Q430 523 439 514Q448 505 448 491V363V251V43Q448 29 439 20Q430 11 416 11ZM64 27Q63 14 50 11Q36 11 32 24L2 160Q0 169 0 179Q1 214 23 238Q46 263 80 267V491Q80 505 89 514Q98 523 112 523Q126 523 135 514Q144 505 144 491V267Q178 263 201 238Q223 214 224 179Q224 169 222 160L192 24Q188 11 174 11Q161 14 160 27V161Q159 170 150 171Q142 170 140 162L128 26Q126 12 112 11Q98 12 96 26L84 162Q82 170 74 171Q65 170 64 161V27ZM112 179V178V179Z'],++ paste: [512, 'M160 11Q123 13 105 43H48Q28 44 14 57Q1 71 0 91V411Q1 431 14 445Q28 458 48 459H192V187Q193 153 215 130Q238 108 272 107H320V91Q319 71 306 57Q292 44 272 43H215Q197 13 160 11ZM272 139Q252 140 238 153Q225 167 224 187V459V475Q225 495 238 509Q252 522 272 523H464Q484 522 498 509Q511 495 512 475V255Q512 235 498 221L430 153Q416 139 396 139H320ZM160 51Q182 53 184 75Q182 97 160 99Q138 97 136 75Q138 53 160 51Z'],++ account: [448, 'M224 267Q259 267 288 250Q317 233 335 203Q352 173 352 139Q352 105 335 75Q317 45 288 28Q259 11 224 11Q189 11 160 28Q131 45 113 75Q96 105 96 139Q96 173 113 203Q131 233 160 250Q189 267 224 267ZM178 315Q103 317 52 367Q2 418 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q446 418 396 367Q345 317 270 315H178Z'],++ bell: [448, 'M224 11Q210 11 201 20Q192 29 192 43V62Q136 74 101 117Q65 160 64 219V238Q63 310 16 365L8 374Q-5 389 3 408Q12 426 32 427H416Q436 426 445 408Q453 389 440 374L433 365Q385 311 384 238V219Q383 160 347 117Q312 74 256 62V43Q256 29 247 20Q238 11 224 11ZM269 504Q288 485 288 459H224H160Q160 485 179 504Q198 523 224 523Q250 523 269 504Z'],++ history: [512, 'M75 86 41 52 75 86 41 52Q29 41 15 47Q1 52 0 69V179Q2 201 24 203H134Q151 202 156 188Q162 174 151 162L120 131Q174 77 256 75Q338 77 392 131Q446 185 448 267Q446 349 392 403Q338 457 256 459Q194 458 146 425Q135 417 122 419Q110 422 102 433Q94 444 96 456Q99 469 110 477Q173 522 256 523Q328 522 385 488Q443 454 477 396Q511 339 512 267Q511 195 477 138Q443 80 385 46Q328 12 256 11Q203 11 156 31Q110 51 75 86ZM256 139Q234 141 232 163V267Q232 277 239 284L311 356Q328 370 345 356Q359 339 345 322L280 257V163Q278 141 256 139Z'],++ theme: [512, 'M512 267Q512 268 512 268Q512 269 512 270Q511 297 490 314Q470 331 442 331H344Q324 332 310 345Q297 359 296 379Q296 384 297 389Q300 403 307 417Q307 418 308 419Q318 439 320 461Q320 485 305 503Q291 521 267 523Q261 523 256 523Q184 522 127 488Q69 454 35 396Q1 339 0 267Q1 195 35 138Q69 80 127 46Q184 12 256 11Q328 12 385 46Q443 80 477 138Q511 195 512 267ZM128 299Q128 285 119 276Q110 267 96 267Q82 267 73 276Q64 285 64 299Q64 313 73 322Q82 331 96 331Q110 331 119 322Q128 313 128 299ZM128 203Q142 203 151 194Q160 185 160 171Q160 157 151 148Q142 139 128 139Q114 139 105 148Q96 157 96 171Q96 185 105 194Q114 203 128 203ZM288 107Q288 93 279 84Q270 75 256 75Q242 75 233 84Q224 93 224 107Q224 121 233 130Q242 139 256 139Q270 139 279 130Q288 121 288 107ZM384 203Q398 203 407 194Q416 185 416 171Q416 157 407 148Q398 139 384 139Q370 139 361 148Q352 157 352 171Q352 185 361 194Q370 203 384 203Z'],++ check: [448, 'M439 116Q448 126 448 139Q448 152 439 162L183 418Q173 427 160 427Q147 427 137 418L9 290Q0 280 0 267Q0 254 9 244Q19 235 32 235Q45 235 55 244L160 350L393 116Q403 107 416 107Q429 107 439 116Z'],++ error: [512, 'M256 43Q279 44 291 63L507 431Q517 451 507 471Q495 490 472 491H40Q17 490 5 471Q-5 451 5 431L222 63Q234 44 256 43ZM256 171Q234 173 232 195V307Q234 329 256 331Q278 329 280 307V195Q278 173 256 171ZM288 395Q288 381 279 372Q270 363 256 363Q242 363 233 372Q224 381 224 395Q224 409 233 418Q242 427 256 427Q270 427 279 418Q288 409 288 395Z'],++ clip: [512, 'M256 203 217 164 256 203 217 164Q224 145 224 123Q223 75 191 44Q160 12 112 11Q64 12 33 44Q1 75 0 123Q1 171 33 202Q64 234 112 235Q134 235 153 228L192 267L153 307Q134 299 112 299Q64 300 33 332Q1 363 0 411Q1 459 33 490Q64 522 112 523Q160 522 191 490Q223 459 224 411Q224 389 217 371L499 88Q510 75 499 62Q477 41 448 41Q419 41 397 62L256 203ZM279 354 397 472 279 354 397 472Q419 493 448 493Q477 493 499 472Q510 459 499 446L343 290L279 354ZM64 123Q65 96 88 81Q112 69 136 81Q159 96 160 123Q159 150 136 165Q112 177 88 165Q65 150 64 123ZM112 363Q139 364 154 387Q166 411 154 435Q139 458 112 459Q85 458 70 435Q58 411 70 387Q85 364 112 363Z'],++ };++ // END generated solid glyphs++ // A glyph drawn as a solid Font Awesome path, fitted to the 24-box the stroke glyphs use.++ // Names without a solid twin keep their stroke glyph (the brand "V", YouTube, logo, ...).++ function glyphMarkup(key, def, forSprite) {++ const solid = SOLID[key];++ if (!solid) return forSprite ? def.glyph : `<g class="ovi-glyph">${def.glyph}</g>`;++ const [w, d] = solid; const sc = 18 / 512; const tx = (24 - w * sc) / 2, ty = 3;++ const paint = forSprite ? ' fill="currentColor" stroke="none" style="fill:var(--ovi-glyph,var(--text-primary,#e6edf7))"' : '';++ // The position/scale transform must live on a plain (unclassed) <g>, not on the .ovi-glyph++ // element itself: that class sets transform-box/transform-origin for the hover-scale++ // animation, and browsers resolve a "transform" *attribute* through the same CSS transform++ // pipeline — so pairing it with a non-default transform-origin pivots the glyph around++ // (12,12) instead of applying it in place, throwing every solid glyph off-centre.++ return `<g class="ovi-glyph ovi-glyph--solid"${paint}><g transform="translate(${tx.toFixed(2)} ${ty}) scale(${sc.toFixed(5)})"><path d="${d}"/></g></g>`;++ }++++ 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 key = resolve(name); const def = registry[key];++ const off = def.offset || OFFSETS[key] || [0, 0]; // measured optical centring, see scripts/measure-icons.js++ 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 transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)">${glyphMarkup(key, def, false)}</g></svg>`;++ }++++ // ── Sprite mode (server-rendered lists) ──────────────────────────────────────────────++ // A page that shows 170 icons should not ship 170 copies of the same ring and glyph. sprite(names) emits each++ // icon once as a <symbol>; use(name) references it. Colours come from currentColor and inherited custom++ // properties, so a referenced icon is themed exactly like an inline one. Static by design (no comet, no++ // progress): pages use it for listings and keep the full inline icon where state or motion matters.++ function sprite(names) {++ const seen = new Set();++ const symbols = (names || []).map(resolve).filter(n => !seen.has(n) && seen.add(n)).map((key) => {++ const def = registry[key]; const off = def.offset || OFFSETS[key] || [0, 0];++ return `<symbol id="ovi-${key}" viewBox="0 0 48 48"><circle cx="24" cy="24" r="21" fill="currentColor" opacity=".1"/><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="2.4" opacity=".3"/><g transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--ovi-glyph,var(--text-primary,#e6edf7))">${glyphMarkup(key, def, true)}</g></symbol>`;++ }).join('');++ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">${symbols}</svg>`;++ }++ function use(name, size) {++ const key = resolve(name);++ return `<span class="ov-icon" data-icon="${key}" data-ovi="${key}" data-fx="none" style="--ovi-size:${parseInt(size, 10) || 32}px" aria-hidden="true"><svg viewBox="0 0 48 48" focusable="false"><use href="#ovi-${key}"/></svg></span>`;++ }++++ 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, offset: Array.isArray(def.offset) ? def.offset : 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, sprite, use, 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);nnew file mode 100644@@@ -0,0 +1,191 @@++/**++ * 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.++ *++ * Motion budget: SVG animations restyle and re-lay-out on the main thread every frame, so an++ * endlessly moving mark kept idle pages 12-29% busy (browser check, OpenVibe.Host). A mark now++ * moves only for its intro (the first INTRO_MS of a visit, from the first time a mark is on screen++ * in this tab; later pages of a multi-page site open with the mark at rest), while it or its link++ * is hovered or focused (and LINGER_MS after), and while it shows activity (data-state busy/ok,++ * island.js). Otherwise its CSS animations are paused where they are and its SMIL clock stopped,++ * so it keeps its look. It never moves while the page is hidden, while it is off-screen++ * (IntersectionObserver), with data-static, under prefers-reduced-motion or under the network's++ * own reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js).++ */++(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[data-ov]:not([data-ovm-run]) svg,.ov-mark[data-ov]:not([data-ovm-run]) .g,.ov-mark[data-ov]:not([data-ovm-run]) .c,.ov-mark[data-ov]:not([data-ovm-run]) .d,.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .v{animation-play-state:paused!important}++.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>`;++ }++ const INTRO_MS = 8000, LINGER_MS = 1200;++ const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }++ let io = null, rm = null;++ // One intro per visit (tab): the deadline is kept in sessionStorage so the next page of a++ // multi-page site finishes the same intro instead of starting another.++ const INTRO_KEY = 'ov-mark-intro-end';++ function introDeadline(now) {++ let end = 0;++ try { end = Number(sessionStorage.getItem(INTRO_KEY)) || 0; } catch { /* no storage */ }++ if (!end || end > now + INTRO_MS) { end = now + INTRO_MS; try { sessionStorage.setItem(INTRO_KEY, String(end)); } catch { /* */ } }++ return end;++ }++ function reduced() {++ return !!(rm && rm.matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced';++ }++ function update(el) {++ const st = state.get(el); if (!st) return;++ const svgEl = el.querySelector('svg');++ if (!el.isConnected || !svgEl) { forget(el); return; }++ const now = Date.now();++ const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;++ if (may && !st.introEnd) st.introEnd = introDeadline(now);++ const s = el.getAttribute('data-state');++ const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');++ if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }++ try {++ if (run) svgEl.unpauseAnimations();++ else {++ // Never run yet: rest on the frame where the V's light sweep is centred.++ if (svgEl.getCurrentTime() < 0.5) svgEl.setCurrentTime(1.6);++ svgEl.pauseAnimations();++ }++ } catch { /* no SMIL */ }++ clearTimeout(st.timer); st.timer = 0;++ const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now));++ if (run && next !== Infinity) st.timer = setTimeout(() => update(el), next - now + 20);++ }++ // A mark taken out of the page stops and is let go (a navbar re-render replaces its mark);++ // mount() picks it up again if it comes back.++ function forget(el) {++ const st = state.get(el); if (!st) return;++ clearTimeout(st.timer); state.delete(el);++ if (io) try { io.unobserve(el); } catch { /* */ }++ el.removeAttribute('data-ovm-run');++ try { el.querySelector('svg').pauseAnimations(); } catch { /* */ }++ }++ function updateAll() { document.querySelectorAll('.ov-mark[data-ov]').forEach(update); }++ const bound = new WeakSet();++ function track(el) {++ state.set(el, { introEnd: 0, lingerEnd: 0, hover: false, focus: false, onScreen: io ? null : true, timer: 0 });++ if (!bound.has(el)) {++ bound.add(el);++ const host = el.closest('a,button') || el;++ const set = (k, v) => () => {++ const st = state.get(el); if (!st) return;++ st[k] = v; if (!v) st.lingerEnd = Date.now() + LINGER_MS;++ update(el);++ };++ host.addEventListener('pointerenter', set('hover', true)); host.addEventListener('pointerleave', set('hover', false));++ host.addEventListener('focusin', set('focus', true)); host.addEventListener('focusout', set('focus', false));++ }++ if (io) io.observe(el);++ update(el);++ }++ function mount(root) {++ (root || document).querySelectorAll('.ov-mark').forEach(el => {++ if (!el.hasAttribute('data-ov')) {++ 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) + '_');++ }++ if (!state.has(el) && el.isConnected) track(el);++ });++ }++ 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); }++ try {++ io = new IntersectionObserver((entries) => entries.forEach((e) => {++ const st = state.get(e.target); if (!st) return;++ st.onScreen = e.isIntersecting; update(e.target);++ }));++ } catch { io = null; }++ try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }++ document.addEventListener('visibilitychange', updateAll);++ mount();++ try {++ new MutationObserver((records) => {++ let added = false;++ for (const r of records) {++ if (r.type === 'attributes') { if (r.target === document.documentElement) updateAll(); else if (state.has(r.target)) update(r.target); continue; }++ if (r.addedNodes.length) added = true;++ r.removedNodes.forEach((node) => {++ if (node.nodeType !== 1) return;++ if (state.has(node) && !node.isConnected) forget(node);++ if (node.querySelectorAll) node.querySelectorAll('.ov-mark[data-ov]').forEach((m) => { if (!m.isConnected) forget(m); });++ });++ }++ if (added) mount();++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion'] });++ } catch { /* */ }++ }++ window.ovMarkMount = mount;++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();++})();nnew file mode 100644@@@ -0,0 +1,53 @@++/**++ * openvibe-shared/panels.js — one coordinator for every dropdown, drawer and popover on a page.++ *++ * Sites keep their own markup and their own open/close toggles. They register each panel here and get the++ * behaviour that should be identical everywhere:++ * • one panel open at a time (opening one closes the others)++ * • Escape and a device rotation close everything++ * • <body class="ov-panel-open"> while any panel is open (floating buttons step aside)++ * • a freshly opened panel starts scrolled to the top++ *++ * OpenVibePanels.register({ el, openClass: 'show', close: () => …, id?: 'user-menu' })++ * OpenVibePanels.closeAll(exceptEl?) · OpenVibePanels.anyOpen()++ *++ * It watches the panel's class instead of wrapping the site's click handlers, so it cannot fight them: a++ * panel is "open" exactly when its element has `openClass`, however that came about.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibePanels) return;++ if (typeof document === 'undefined') { const stub = { register() {}, closeAll() {}, anyOpen: () => false }; if (typeof module !== 'undefined') module.exports = stub; return; }++++ const panels = []; // { el, openClass, close, id }++ const isOpen = (p) => p.el.isConnected && p.el.classList.contains(p.openClass);++ const anyOpen = () => panels.some(isOpen);++ const syncBody = () => document.body && document.body.classList.toggle('ov-panel-open', anyOpen());++++ function closeAll(except) {++ // A panel nested in another (a submenu inside the mobile drawer) never closes its container, or vice versa.++ const related = (a, b) => a && b && (a.contains(b) || b.contains(a));++ for (const p of panels) if (p.el !== except && !related(p.el, except) && isOpen(p)) { try { p.close(); } catch { p.el.classList.remove(p.openClass); } }++ syncBody();++ }++++ function register(opts) {++ const el = opts && opts.el; if (!el || panels.some(p => p.el === el)) return;++ const p = { el, openClass: opts.openClass || 'open', id: opts.id || '', close: typeof opts.close === 'function' ? opts.close : () => el.classList.remove(opts.openClass || 'open') };++ panels.push(p);++ let was = isOpen(p);++ new MutationObserver(() => {++ const now = isOpen(p); if (now === was) return; was = now;++ if (now) { closeAll(el); try { el.scrollTop = 0; } catch { /* */ } }++ syncBody();++ }).observe(el, { attributes: true, attributeFilter: ['class'] });++ syncBody();++ }++++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && anyOpen()) closeAll(); });++ root.addEventListener('orientationchange', () => closeAll(), { passive: true });++++ const api = { register, closeAll, anyOpen };++ root.OpenVibePanels = api;++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,161 @@++/*++ * openvibe-shared/release-update.js — plans a new release for an open tab and applies it in place++ * (ADR-016, Track R). release-watch.js loads it from its own directory when /release.json reports a++ * release with components or contract ranges; pages never load it themselves. In Node, require() returns++ * the pure plan(), compatible() and satisfies() (openvibe-shared/release-compat builds on them).++ *++ * In place is transactional: every new stylesheet loads and every region is fetched before anything old++ * is removed; a rejection (style, style-timeout, content, origin) leaves the page as it was.++ * styles(ids, next) new <link>s for the changed style components' assets, loaded beside the old ones++ * regions(ids) fresh [data-ov-content="<component>"] HTML (from data-ov-src, default this URL),++ * without scripts, frames or inline handlers; unchanged data-ov-rev is skipped++ * commit(list, next, busy, record) replaces the regions busy() allows, returns those still waiting; each++ * gets ov:content-dispose first (a widget's code drops its listeners and timers) and++ * ov:content-updated after (it mounts again), both bubbling with { component, release }++ * apply(plan, next, busy, record) all of the above for an in-place plan++ */++(function (root) {++ 'use strict';++ const IN_PLACE = { style: 1, content: 1, server: 1 };++ const ver = (v) => { const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '')); return m ? [+m[1], +m[2], +m[3]] : null; };++ const cmp = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; return 0; };++ /** version inside ">=a.b.c <x.y.z", the only form a manifest writes. */++ function satisfies(version, range) {++ const v = ver(version); const m = /^>=(\S+) <(\S+)$/.exec(String(range || ''));++ const lo = m && ver(m[1]); const hi = m && ver(m[2]);++ return !!(v && lo && hi && cmp(v, lo) >= 0 && cmp(v, hi) < 0);++ }++ /** Every contract the page's release produces is still accepted by the server, and the reverse. */++ function compatible(page, server) {++ const problems = []; const mine = (page && page.contract_ranges) || {}; const theirs = (server && server.contract_ranges) || {};++ for (const id of Object.keys(mine)) {++ const a = mine[id]; const b = theirs[id];++ if (!a || a.role === 'consumes') continue;++ if (!b || b.role === 'consumes') problems.push(`${id}: the server no longer serves it`);++ else if (!satisfies(a.version, b.accepts)) problems.push(`${id}: the server accepts ${b.accepts}, the page speaks ${a.version}`);++ else if (!satisfies(b.version, a.accepts)) problems.push(`${id}: the page accepts ${a.accepts}, the server speaks ${b.version}`);++ }++ return { ok: !problems.length, problems };++ }++ /**++ * What a tab on `page` (the manifest of the release it runs) does about `server`:++ * { action: none | in-place | prompt | reload, reason?: contract | required | window, changed, problems? }.++ * In place only when every changed component is style, content (same kind on both sides) or server.++ */++ function plan(page, server, now) {++ if (!server || !page || server.release === page.release) return { action: 'none', changed: [] };++ const c = compatible(page, server);++ if (!c.ok) return { action: 'reload', reason: 'contract', changed: [], problems: c.problems };++ // Manifest 1.2.0: a client generation below the server's minimum reloads (required); a shell change never goes in place.++ const pg = page.client_generation; const mg = server.min_client_generation;++ if (Number.isInteger(pg) && Number.isInteger(mg) && pg < mg) return { action: 'reload', reason: 'required', changed: [] };++ const shellMoved = !!(page.shell || server.shell) && (page.shell && page.shell.version) !== (server.shell && server.shell.version);++ const a = page.components; const b = server.components; let changed = null;++ if (a && b) {++ let ok = true; changed = [];++ for (const id of Object.keys(Object.assign({}, a, b))) {++ const x = a[id]; const y = b[id];++ if (x && y && x.kind === y.kind && x.version === y.version) continue;++ const kind = (y || x).kind;++ changed.push({ id, kind });++ if (!IN_PLACE[kind] || (x && y ? x.kind !== y.kind : kind !== 'server')) ok = false;++ }++ if (ok && !shellMoved) return { action: 'in-place', changed };++ }++ const windowMs = (Number(server.mixed_version_window_hours) || 0) * 3600e3;++ if (server.min_client_release === server.release) return { action: 'reload', reason: 'required', changed };++ if (windowMs > 0 && (now == null ? Date.now() : +now) - Date.parse(server.released_at) > windowMs) return { action: 'reload', reason: 'window', changed };++ return { action: 'prompt', changed };++ }++ if (typeof module !== 'undefined' && module.exports) module.exports = { plan, compatible, satisfies };++ if (typeof document === 'undefined' || root.OVReleaseUpdate) return;++++ const loc = () => root.location.href;++ const sameOrigin = (u) => { try { return new URL(u, loc()).origin === new URL(loc()).origin; } catch { return false; } };++++ function styles(ids, next) {++ const jobs = [];++ for (const p of Object.keys(next.assets || {})) {++ const a = next.assets[p];++ if (!ids[a.component]) continue;++ const want = new URL(a.url, loc());++ for (const l of document.querySelectorAll('link[rel~="stylesheet"]')) {++ const have = new URL(l.getAttribute('href') || '', loc());++ if (l.getAttribute('data-ov-asset') !== p && (have.origin !== want.origin || (have.pathname !== want.pathname && have.pathname !== p))) continue;++ if (have.href === want.href) continue;++ if (have.origin !== want.origin || !/^https?:$/.test(want.protocol)) return Promise.reject('origin');++ const n = l.cloneNode(false);++ n.setAttribute('href', want.href);++ if (a.integrity) n.setAttribute('integrity', a.integrity); else n.removeAttribute('integrity');++ jobs.push({ old: l, n });++ }++ }++ return Promise.all(jobs.map((j) => new Promise((ok, no) => {++ const timer = root.setTimeout(() => no('style-timeout'), 15000);++ j.n.addEventListener('load', () => { root.clearTimeout(timer); ok(); });++ j.n.addEventListener('error', () => { root.clearTimeout(timer); no('style'); });++ j.old.parentNode.insertBefore(j.n, j.old.nextSibling);++ }))).then(() => jobs, (why) => { jobs.forEach((j) => j.n.remove()); throw why; });++ }++++ async function regions(ids) {++ const out = []; const docs = {};++ for (const el of document.querySelectorAll('[data-ov-content]')) {++ const id = el.getAttribute('data-ov-content');++ if (!ids[id] || !/^[a-z][a-z0-9-]{0,39}$/.test(id)) continue;++ const src = new URL(el.getAttribute('data-ov-src') || loc(), loc()); src.hash = '';++ if (!sameOrigin(src.href)) throw 'origin';++ if (!docs[src.href]) {++ const r = await root.fetch(src.href, { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'text/html' } });++ if (!r.ok) throw 'content';++ docs[src.href] = new root.DOMParser().parseFromString(await r.text(), 'text/html');++ }++ const doc = docs[src.href];++ const fresh = doc.querySelector(`[data-ov-content="${id}"]`) || (el.hasAttribute('data-ov-src') ? doc.body : null);++ if (!fresh) throw 'content';++ for (const x of fresh.querySelectorAll('script, iframe, object, embed, base, frame, meta')) x.remove();++ for (const x of [fresh, ...fresh.querySelectorAll('*')]) {++ for (const at of Array.from(x.attributes)) {++ if (/^on/i.test(at.name) || (/^(href|src|action|formaction|xlink:href|srcdoc)$/i.test(at.name) && /^\s*(javascript|vbscript|data):/i.test(at.value) && !/^\s*data:image\//i.test(at.value))) x.removeAttribute(at.name);++ }++ }++ const rev = fresh.getAttribute('data-ov-rev');++ if (rev && rev === el.getAttribute('data-ov-rev')) continue;++ out.push({ el, fresh, id });++ }++ return out;++ }++++ function commit(list, next, busy, record) {++ return list.filter((w) => {++ if (!w.el.isConnected) return false;++ const why = busy(w.el);++ if (why) { record('deferred', why, true); return true; }++ const r = w.el.getBoundingClientRect();++ const st = w.el.scrollTop;++ const ev = (n) => { try { w.el.dispatchEvent(new root.CustomEvent(n, { bubbles: true, detail: { component: w.id, release: next.release } })); } catch { /* */ } };++ ev('ov:content-dispose');++ w.el.replaceChildren(...Array.from(w.fresh.childNodes).map((n) => document.importNode(n, true)));++ const rev = w.fresh.getAttribute('data-ov-rev');++ if (rev) w.el.setAttribute('data-ov-rev', rev);++ w.el.scrollTop = st;++ // A region above the viewport that changed height must not move what the reader is looking at.++ if (r.bottom <= 0 && root.scrollBy) root.scrollBy(0, w.el.getBoundingClientRect().bottom - r.bottom);++ ev('ov:content-updated');++ return false;++ });++ }++++ /** Applies an in-place plan; resolves with the regions still waiting for commit(), rejects with a reason. */++ async function apply(p, next, busy, record) {++ const ids = {}; const k = {};++ for (const ch of p.changed) { ids[ch.id] = 1; k[ch.kind] = 1; }++ const list = k.content ? await regions(ids) : [];++ const jobs = k.style ? await styles(ids, next) : [];++ jobs.forEach((j) => j.old.remove());++ if (jobs.length) try { root.dispatchEvent(new root.CustomEvent('ov:styles-updated', { detail: { release: next.release } })); } catch { /* */ }++ return commit(list, next, busy, record);++ }++++ root.OVReleaseUpdate = { plan, compatible, satisfies, styles, regions, commit, apply };++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,296 @@++/*++ * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R).++ * Reads /release.json on focus, visibility, reconnect and every 10 min. Changes with components or contract++ * ranges are planned by release-update.js (loaded on demand from this directory): style, content and server++ * go in place, anything else prompts. It reloads by itself only when it must (min_client_release or++ * min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no++ * focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live++ * camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).++ * Release notifications (WS-P task 9): one anonymous EventSource on Events' realtime stream++ * (host.release.published); a new release of this service runs check(true) after a 0-20 s jitter, at++ * most every 30 s. Polling stays the fallback.++ * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta++ * tag may carry data-service, data-events and data-generation. See README "Releases".++ */++(function (root) {++ if (typeof document === 'undefined' || root.OVRelease) return;++ const cfg = root.OVReleaseConfig || {};++ const self = (document.currentScript && document.currentScript.src) || '';++ const SELF_RE = /release-watch\.js(?:[?#].*)?$/;++ const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`;++ const meta = document.querySelector('meta[name="ov-release"]');++ let current = (meta && meta.content) || null;++ const gen = meta && meta.hasAttribute('data-generation') ? +meta.getAttribute('data-generation') : undefined;++ const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json';++ const IDLE_MS = 2 * 60 * 1000;++ const MIN_GAP_MS = 60 * 1000;++ let base = null; // the manifest of the release this page runs++ let latest = null;++ let lastCheck = 0;++ let lastInput = Date.now();++ let prompted = false;++ let mustReload = false; let reloadWhy = null;++ let busyWith = null; // the plan/apply in flight++ let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;++ let counts = {}; const totals = {}; const noted = {};++ let session = null; let lastBeat = 0;++ try { session = Array.from(root.crypto.getRandomValues(new Uint8Array(8)), (b) => (b | 256).toString(16).slice(1)).join(''); } catch { /* no beats */ }++++ ['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));++++ // ── Update metrics (D46): counted here, sent on hide, after an update and before a reload ──++ function record(outcome, reason, once) {++ const k = `${latest && latest.release}|${outcome}|${reason}`;++ if (once) { if (noted[k]) return; noted[k] = 1; }++ for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; }++ }++ // beat: send even with nothing counted; ended: the tab is going++ function flush(beat, ended) {++ const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url);++ const any = Object.keys(counts).length > 0;++ if (!to || (!any && !(beat === true && session && current))) return;++ try {++ if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return;++ const report = { service: latest && latest.service, release: current, to: latest && latest.release };++ if (any) report.counts = counts;++ if (session) { report.session = session; lastBeat = Date.now(); if (ended === true) report.ended = true; }++ const body = JSON.stringify(report);++ counts = {};++ const n = root.navigator;++ if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {});++ } catch { /* best effort */ }++ }++++ /** Why acting now is not safe, or null. With `scope`, only what is inside that element counts. */++ function busy(scope) {++ const d = scope || document;++ const a = document.activeElement;++ const typing = a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || ''));++ if (scope ? a && a !== document.body && scope.contains(a) : typing) return typing ? 'typing' : 'active';++ if (d.querySelector('form[data-dirty="true"]') || (scope && scope.matches('form[data-dirty="true"]'))) return 'dirty';++ if (d.querySelector('[data-ov-protected]') || (scope && scope.hasAttribute('data-ov-protected'))) return 'protected';++ // Someone watching or broadcasting: a playing <video>/<audio>, or a live camera/mic stream.++ try {++ for (const m of d.querySelectorAll('video, audio')) {++ if (!m.paused && !m.ended) return 'media';++ const so = m.srcObject;++ if (so && typeof so.getTracks === 'function' && so.getTracks().some((t) => t.readyState === 'live')) return 'capture';++ }++ } catch { return 'media'; }++ if (scope) return null;++ try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return 'protected'; } catch { return 'protected'; }++ return !document.hidden && Date.now() - lastInput < IDLE_MS ? 'active' : null;++ }++++ function reload(why) { record('reloaded', why); flush(); root.location.reload(); }++ function prompt() {++ if (prompted) return;++ prompted = true;++ record('prompted', mustReload ? reloadWhy : 'optional');++ if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {++ root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } });++ }++ }++ function maybeReload() {++ if (!mustReload) return;++ const why = busy();++ if (why) record('deferred', why, true); else reload(reloadWhy);++ }++ function emit(name, release) { try { root.dispatchEvent(new root.CustomEvent(name, { detail: { release } })); } catch { /* */ } }++++ function loadUpdate() {++ if (root.OVReleaseUpdate) return Promise.resolve(root.OVReleaseUpdate);++ return lib || (lib = new Promise((ok, no) => {++ const sc = document.createElement('script');++ sc.src = updateUrl; sc.async = true;++ sc.onload = () => (root.OVReleaseUpdate ? ok(root.OVReleaseUpdate) : no('script'));++ sc.onerror = () => { lib = null; no('script'); };++ document.head.appendChild(sc);++ }));++ }++ /** Without release-update.js: the release id, min_client_release and the window only. */++ function legacy(m, pg) {++ const w = (Number(m.mixed_version_window_hours) || 0) * 3600e3;++ if (m.min_client_release === m.release || pg < m.min_client_generation) return { action: 'reload', reason: 'required' };++ return w > 0 && Date.now() - Date.parse(m.released_at) > w ? { action: 'reload', reason: 'window' } : { action: 'prompt' };++ }++ function commitRegions(next) {++ if (waiting.length) waiting = root.OVReleaseUpdate.commit(waiting, next, busy, record);++ return !waiting.length;++ }++ function adopt(next) {++ base = next; current = next.release; pending = null; mustReload = false;++ record('applied', kinds || 'server');++ flush();++ emit('ov:release-applied', next.release);++ }++++ async function decide(m) {++ let x = null;++ if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }++ const page = base || { release: current, client_generation: gen };++ let p = x ? x.plan(page, m, Date.now()) : legacy(m, page.client_generation);++ if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());++ if (p.action === 'none') return;++ if (p.action !== 'in-place') {++ if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; }++ prompt();++ maybeReload();++ return;++ }++ try {++ kinds = Array.from(new Set(p.changed.map((c) => c.kind))).sort().join('+');++ waiting = await x.apply(p, m, busy, record); pending = m;++ if (!waiting.length) adopt(m);++ } catch (why) {++ waiting = []; pending = null;++ record('failed', typeof why === 'string' ? why : 'content');++ failedRelease = m.release;++ flush();++ await decide(m);++ }++ }++ function consider(m) {++ latest = m;++ if (m.release === current || busyWith || (pending && pending.release === m.release)) return;++ waiting = []; pending = null; // a newer release supersedes regions still waiting for the last one++ busyWith = decide(m).catch(() => {}).then(() => { busyWith = null; });++ }++++ async function check(force) {++ const now = Date.now();++ if (!force && now - lastCheck < MIN_GAP_MS) return latest;++ lastCheck = now;++ let m;++ try {++ const r = await root.fetch(url, { cache: 'no-store', credentials: 'omit' });++ if (!r.ok) return latest;++ m = await r.json();++ } catch { return latest; }++ if (!m || typeof m.release !== 'string') return latest;++ if (!current) current = m.release;++ if (!base && m.release === current) base = m;++ consider(m);++ live();++ if (busyWith) await busyWith;++ return m;++ }++++ // ── Release notifications (host.release.published, public, via OpenVibe.Events) ──++ // One credential-less EventSource per tab (account switches change nothing). Closed after 5 min hidden,++ // reopened with last_event_id; errors back off 30 s to 15 min; after 6 failures only polling is left.++ const TOPIC = 'host.release.published';++ const HEX = /^[0-9a-f]{7,40}$/;++ const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastSeq: null };++ let es = null; let queued = null; let again = false; let seen = [];++ const t = {}; // timers: run (the jittered check), cool (30 s after it), retry, hide++ const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); };++ const same = (a, b) => !!a && !!b && (a === b || (HEX.test(a) && HEX.test(b) && (a.startsWith(b) || b.startsWith(a))));++ function eventsUrl() {++ const v = 'eventsUrl' in cfg ? cfg.eventsUrl : meta && meta.hasAttribute('data-events') ? meta.getAttribute('data-events') : undefined;++ if (v !== undefined) return v && v !== 'off' ? String(v) : null;++ try { return new URL(root.location.href).protocol === 'https:' ? 'https://events.openvibe.network/realtime/stream' : null; } catch { return null; }++ }++ function live() {++ if (stopped || es || rt.state !== 'off' || typeof root.EventSource !== 'function') return;++ const svc = cfg.service || (meta && meta.getAttribute('data-service')) || (base && base.service) || (latest && latest.service);++ const at = eventsUrl();++ if (!at || !/^[a-z][a-z0-9-]{1,39}$/.test(String(svc || ''))) return;++ rt.service = svc; rt.url = at;++ open();++ if (document.hidden) later('hide', hide, 5 * 60 * 1000);++ }++ function open() {++ let u;++ try { u = new URL(rt.url, root.location.href); u.searchParams.set('topics', TOPIC); if (rt.lastSeq != null) u.searchParams.set('last_event_id', rt.lastSeq); } catch { rt.state = 'failed'; return; }++ rt.state = 'connecting';++ let s;++ try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; }++ s.onopen = () => { if (es === s) { rt.state = 'open'; rt.failures = 0; } };++ s.onmessage = (e) => { if (es === s) heard(e); };++ s.onerror = () => { if (es === s) fail(); };++ s.addEventListener('gap', () => { if (es === s) queue(); }); // events were missed: check anyway++ }++ function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }++ function fail() {++ close();++ if (rt.state === 'blocked') return;++ if (++rt.failures >= 6) { rt.state = 'failed'; return; }++ rt.state = 'backoff';++ later('retry', () => { if (document.hidden) rt.state = 'hidden'; else open(); }, Math.min(9e5, 3e4 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));++ }++ function hide() { if (document.hidden && es) { close(); rt.state = 'hidden'; } }++ // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.++ document.addEventListener('securitypolicyviolation', (e) => {++ try { if (!rt.url || String(e.blockedURI || '').indexOf(new URL(rt.url, root.location.href).origin) !== 0) return; } catch { return; }++ close(); root.clearTimeout(t.retry); t.retry = null; rt.state = 'blocked';++ });++ function heard(e) {++ let m; try { m = JSON.parse(e.data); } catch { return; }++ const ev = m && m.event; const p = ev && ev.payload;++ if (!p || ev.event_type !== TOPIC) return;++ if (typeof m.seq === 'number') { if (rt.lastSeq != null && m.seq <= rt.lastSeq) return; rt.lastSeq = m.seq; }++ if (p.service !== rt.service || typeof p.release !== 'string') return;++ // Ids repeat across origins (Sites' placeholders): a named origin must be ours.++ if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }++ rt.events++;++ if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }++ seen = seen.concat(ev.event_id).slice(-20);++ queued = p.release;++ queue();++ }++ /** One check after a 0-20 s jitter; what arrives meanwhile or 30 s after collapses into one more. */++ function queue() {++ if (t.run) return;++ if (t.cool) { again = true; return; }++ later('run', () => {++ const q = queued;++ rt.checks++;++ later('cool', () => { if (again) { again = false; queue(); } }, 30 * 1000);++ if (!stopped) check(true).then(() => { if (queued === q) queued = null; });++ }, Math.floor(Math.random() * 20 * 1000));++ }++++ let stopped = false;++ root.addEventListener('focus', () => { if (!stopped) check(false); });++ root.addEventListener('online', () => { if (stopped) return; if (rt.state === 'failed') { rt.state = 'off'; rt.failures = 0; live(); } check(true); });++ root.addEventListener('pagehide', () => flush(true, true));++ document.addEventListener('visibilitychange', () => {++ if (stopped) return;++ if (document.hidden) { flush(); maybeReload(); if (es) later('hide', hide, 5 * 60 * 1000); return; }++ root.clearTimeout(t.hide); t.hide = null;++ if (rt.state === 'hidden') open();++ check(false);++ });++ const tick = root.setInterval(() => {++ if (stopped) return;++ if (pending && commitRegions(pending)) adopt(pending);++ check(false); maybeReload();++ if (Date.now() - lastBeat >= 3e5) flush(true);++ }, 30 * 1000);++ const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);++ function stop() {++ stopped = true; root.clearInterval(tick); root.clearInterval(poll);++ close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); rt.state = 'off';++ }++ root.OVRelease = {++ get current() { return current; },++ check: () => check(true),++ state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals, realtime: { ...rt } }),++ flush,++ stop,++ };++ // The page's own manifest (if still served). No /release.json and no meta tag: left alone.++ lastCheck = Date.now();++ root.fetch(url, { cache: 'no-store', credentials: 'omit' })++ .then((r) => (r.ok ? r.json() : null))++ .then((m) => {++ if (!m || typeof m.release !== 'string') { if (!current) stop(); return; }++ if (!current) current = m.release;++ if (m.release === current) base = m;++ consider(m);++ live();++ flush(true);++ })++ .catch(() => { if (!current) stop(); });++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,375 @@++/*!++ * openvibe-shared/shipped — what shipped, on every OpenVibe site, from one feed: the network changelog++ * OpenVibe.Blog keeps (every deployed commit of every site) and serves through OpenVibe.Network at++ * GET https://openvibe.network/api/v1/changelog, which every site's CSP already allows.++ *++ * Three views, the same on every site:++ * latest the one-liner "🚀 shipped 5m ago: <newest change>", linking to the site's updates page++ * list the newest few changes, each linked to its commit, plus the latest "Patch notes" post++ * log the full updates page: changes grouped by day, "Load more", this site or the whole++ * network (with a filter per site), and the patch notes posts++ *++ * Markup only (the script mounts every element on the page, and the shared footer loads it):++ * <a data-ov-shipped="latest" data-service="wiki" href="/updates"></a>++ * <section data-ov-shipped="list" data-service="wiki" data-limit="8" data-more="/updates"></section>++ * <div data-ov-shipped="log" data-service="wiki"></div> data-service="" = the whole network++ * <script src="https://openvibe.network/shared/shipped.js" defer></script>++ *++ * Or from code: OpenVibeShipped.latest(el, opts), .list(el, opts) (alias mount), .log(el, opts),++ * .scan(root). Options: service (a registry id; 'auto' = from the hostname; omitted = every site),++ * limit, title ('' = no heading), href / more (links), api (the feed URL, for tests). DOM nodes only++ * (commit text is never parsed as HTML), https links only; a failed or empty feed hides the element,++ * never an error on the page. Styles follow the theme's CSS variables. A classic script, no modules.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ if (typeof window !== 'undefined') {++ window.OpenVibeShipped = api;++ if (typeof document !== 'undefined') {++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => api.scan(document));++ else api.scan(document);++ }++ }++}(typeof globalThis !== 'undefined' ? globalThis : this, function () {++ 'use strict';++ const API = 'https://openvibe.network/api/v1/changelog';++ const SITE_NAMES = { live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing', tips: 'Tips', vip: 'VIP', openre: 'OpenRe', sites: 'Sites', realtime: 'Realtime' };++ const STYLE_ID = 'ov-shipped-style';++ const TEXT = 'var(--text-primary,var(--text,inherit))';++ const MUTED = 'var(--text-secondary,var(--text-muted,#8b93ad))';++ const ACCENT = 'var(--accent-light,var(--accent,#3b82f6))';++ const BORDER = 'var(--border,rgba(127,127,127,.25))';++ const CSS = [++ `.ov-shipped-latest{display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;font-size:.8rem;text-decoration:none;color:${MUTED};background:color-mix(in srgb,var(--bg-secondary,#111827) 70%,transparent);border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);max-width:min(640px,100%);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box}`,++ `.ov-shipped-latest:hover{border-color:var(--accent,#3b82f6);color:${TEXT}}.ov-shipped-latest svg{flex:none;color:var(--accent,#3b82f6)}`,++ `.ov-shipped-latest b{color:${TEXT};font-weight:600;overflow:hidden;text-overflow:ellipsis;min-width:0}`,++ '[data-ov-shipped][hidden],.ov-shipped[hidden],.ov-shipped-latest[hidden]{display:none!important}',++ '.ov-shipped-home{display:grid;gap:12px;justify-items:start;margin:16px 0;min-width:0}.ov-shipped-home>.ov-shipped{justify-self:stretch}',++ `.ov-updates{min-width:0}.ov-updates h1{margin:.2em 0 .3em}.ov-updates-lede{color:${MUTED};line-height:1.55;margin:0 0 18px}`,++ '.ov-shipped{font-size:.92rem;min-width:0}.ov-shipped h3{font-size:1rem;margin:0 0 .5rem;display:flex;gap:.4rem;align-items:center}',++ '.ov-shipped ul{list-style:none;margin:0;padding:0;display:grid;gap:.35rem}.ov-shipped li{display:flex;gap:.5rem;align-items:baseline;min-width:0}',++ `.ov-shipped-site{flex:none;font-size:.72rem;font-weight:700;padding:.05rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:${ACCENT};text-decoration:none}`,++ `.ov-shipped a.ov-shipped-subject{color:${TEXT};text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}`,++ '.ov-shipped a.ov-shipped-subject:hover{text-decoration:underline}.ov-shipped li time{flex:none;margin-left:auto;font-size:.75rem;opacity:.7}',++ `.ov-shipped-major{font-weight:600}.ov-shipped-links{display:flex;flex-wrap:wrap;gap:.4rem 1rem;margin-top:.6rem}.ov-shipped-links a{font-weight:600;color:${ACCENT};text-decoration:none}.ov-shipped-links a:hover{text-decoration:underline}`,++ '.ov-shipped-log{display:flex;flex-direction:column;gap:22px;min-width:0}',++ '.ov-shipped-bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center}',++ `.ov-shipped-chip{font:inherit;font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${MUTED};cursor:pointer;text-decoration:none}`,++ `.ov-shipped-chip:hover{color:${TEXT};border-color:var(--accent,#3b82f6)}.ov-shipped-chip[aria-pressed="true"]{background:var(--accent-strong,#1d4ed8);border-color:var(--accent-strong,#1d4ed8);color:var(--on-accent-strong,#fff)}`,++ `.ov-shipped-day h3{font-size:.95rem;font-weight:600;color:${MUTED};padding-bottom:8px;border-bottom:1px solid ${BORDER};margin:0 0 4px}`,++ '.ov-shipped-entries{display:flex;flex-direction:column;gap:2px}',++ '.ov-shipped-entry{display:flex;align-items:baseline;gap:10px;padding:8px 12px;border-radius:6px;min-width:0}',++ '.ov-shipped-entry:hover{background:var(--bg-tertiary,rgba(127,127,127,.08))}',++ `.ov-shipped-hash{font-family:'JetBrains Mono','Fira Code',ui-monospace,monospace;font-size:.8rem;color:${ACCENT};text-decoration:none;flex:none}.ov-shipped-hash:hover{text-decoration:underline}`,++ `.ov-shipped-text{flex:1;min-width:0;font-size:.9rem;color:${TEXT};overflow-wrap:anywhere}.ov-shipped-text.ov-shipped-major{font-weight:600}`,++ `.ov-shipped-meta{font-size:.75rem;color:${MUTED};flex:none;white-space:nowrap}`,++ `.ov-shipped-more{align-self:center;font:inherit;font-size:.85rem;padding:8px 18px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${TEXT};cursor:pointer}.ov-shipped-more:hover{border-color:var(--accent,#3b82f6)}.ov-shipped-more[disabled]{opacity:.6;cursor:default}`,++ `.ov-shipped-posts{border:1px solid ${BORDER};border-radius:12px;padding:12px 16px}.ov-shipped-posts h3{font-size:.95rem;margin:0 0 8px}.ov-shipped-posts ul{list-style:none;margin:0;padding:0;display:grid;gap:6px}`,++ `.ov-shipped-posts a{color:${ACCENT};text-decoration:none;font-weight:600}.ov-shipped-posts a:hover{text-decoration:underline}.ov-shipped-posts small{color:${MUTED};margin-left:6px}`,++ `.ov-shipped-empty{color:${MUTED};text-align:center;padding:24px 0;margin:0}`,++ '@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}',++ ].join('');++ const ROCKET = 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5';++++ // ── helpers ─────────────────────────────────────────────────++ function ago(iso, now) {++ const t = Date.parse(iso);++ if (!t) return '';++ const s = Math.max(0, ((now || Date.now()) - t) / 1000);++ if (s < 45) return 'just now';++ if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m ago`;++ if (s < 86400) return `${Math.round(s / 3600)}h ago`;++ if (s < 86400 * 45) return `${Math.round(s / 86400)}d ago`;++ return `${Math.round(s / (86400 * 30))}mo ago`;++ }++ function el(tag, attrs, text) {++ const n = document.createElement(tag);++ for (const k of Object.keys(attrs || {})) if (attrs[k] != null) n.setAttribute(k, attrs[k]);++ if (text != null) n.textContent = text;++ return n;++ }++ function style() {++ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return;++ const s = el('style', { id: STYLE_ID });++ s.textContent = CSS;++ (document.head || document.documentElement).appendChild(s);++ }++ function safeUrl(u) { return /^https:\/\//.test(String(u || '')) ? String(u) : null; }++ function localHref(u) { const s = String(u || ''); return /^\/(?!\/)/.test(s) || /^https:\/\//.test(s) ? s : null; }++ function clamp(n, lo, hi, d) { const v = parseInt(n, 10); return Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : d; }++ const siteName = (id) => SITE_NAMES[id] || id;++ function timeEl(iso) { const t = el('time', { datetime: iso, 'data-ov-ago': '' }, ago(iso)); t.title = new Date(iso).toLocaleString(); return t; }++ function rocket() {++ const ns = 'http://www.w3.org/2000/svg';++ const svg = document.createElementNS(ns, 'svg');++ for (const [k, v] of Object.entries({ width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' })) svg.setAttribute(k, v);++ const p = document.createElementNS(ns, 'path');++ p.setAttribute('d', ROCKET);++ svg.appendChild(p);++ return svg;++ }++++ /** The registry id for a hostname (or an id already): openvibe.wiki → wiki, pdf.openvibe.tools → tools. */++ function serviceFor(value) {++ const v = String(value || '').toLowerCase().trim();++ if (SITE_NAMES[v]) return v;++ const h = v.replace(/:\d+$/, '').replace(/^www\./, '');++ if (/(^|\.)openre\.stream$/.test(h)) return 'openre';++ if (/(^|\.)openvibe\.tools$/.test(h)) return 'tools';++ if (/(^|\.)openvibe\.network$/.test(h)) {++ const sub = h.replace(/\.?openvibe\.network$/, '');++ return SITE_NAMES[sub] && sub !== 'network' ? sub : 'network';++ }++ const m = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);++ return m && SITE_NAMES[m[1]] ? m[1] : null;++ }++ /** 'auto' = this page's host; a site id that is not a registry id (a Tools satellite's 'dev') also falls back to the host; '' = the whole network. */++ function resolveService(s) {++ const host = () => (typeof location !== 'undefined' ? serviceFor(location.hostname) : null);++ if (s === 'auto') return host();++ return s ? (serviceFor(s) || host()) : null;++ }++++ // One request per feed URL for 30 s, shared by the footer line, the pill and the list on a page.++ const _feeds = new Map();++ function feed({ service = null, limit = 5, before = null, api = null } = {}) {++ const url = new URL(api || API);++ if (service) url.searchParams.set('service', String(service));++ url.searchParams.set('limit', String(clamp(limit, 1, 100, 5)));++ if (before) url.searchParams.set('before', String(before));++ const key = url.toString();++ const hit = _feeds.get(key);++ if (hit && Date.now() - hit.at < 30000) return hit.promise;++ const promise = fetch(key, { headers: { Accept: 'application/json' }, credentials: 'omit' })++ .then((r) => (r.ok ? r.json() : null)).catch(() => null);++ _feeds.set(key, { at: Date.now(), promise });++ return promise;++ }++++ // "5m ago" stays true while the page is open.++ let _ticker = null;++ function tick() {++ if (_ticker || typeof setInterval === 'undefined' || typeof document === 'undefined') return;++ _ticker = setInterval(() => {++ for (const t of document.querySelectorAll('time[data-ov-ago]')) t.textContent = ago(t.getAttribute('datetime'));++ }, 60000);++ if (_ticker.unref) _ticker.unref();++ }++++ // ── latest: the one-liner ───────────────────────────────────++ /** Render the newest entry into `node` (exported for tests). A node that is an <a> keeps its own href. */++ function renderLatest(node, data, opts) {++ const o = opts || {};++ const e = data && Array.isArray(data.entries) ? data.entries[0] : null;++ node.textContent = '';++ if (!e || !e.subject) { node.hidden = true; return node; }++ node.classList.add('ov-shipped-latest');++ const href = localHref(o.href) || (node.tagName === 'A' && node.getAttribute('href')) || null;++ if (node.tagName === 'A' && href) node.setAttribute('href', href);++ node.appendChild(rocket());++ node.appendChild(document.createTextNode(o.service ? ' shipped ' : ` ${siteName(e.service)} shipped `));++ node.appendChild(timeEl(e.deployed_at));++ node.appendChild(document.createTextNode(': '));++ node.appendChild(el('b', {}, e.subject));++ node.title = e.subject;++ node.hidden = false;++ if (node.style && node.style.display === 'none') node.style.display = '';++ tick();++ return node;++ }++ async function latest(target, opts) {++ const o = { ...(opts || {}) };++ const node = typeof target === 'string' ? document.querySelector(target) : target;++ if (!node) return null;++ o.service = resolveService(o.service);++ style();++ return renderLatest(node, await feed({ service: o.service, limit: 1, api: o.api }), o);++ }++++ // ── list: the newest few ────────────────────────────────────++ /** Render a changelog answer into `node` (exported for tests). */++ function render(node, data, opts) {++ const o = opts || {};++ const entries = (data && Array.isArray(data.entries) ? data.entries : []).slice(0, clamp(o.limit, 1, 20, 5));++ node.textContent = '';++ if (!entries.length) { node.hidden = true; return node; }++ node.classList.add('ov-shipped');++ if (o.title !== '') node.appendChild(el('h3', {}, o.title || 'Recently shipped'));++ const ul = el('ul');++ const showSite = o.showSite != null ? o.showSite : !o.service;++ for (const e of entries) {++ const li = el('li');++ if (showSite) li.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));++ const href = safeUrl(e.url);++ const a = el(href ? 'a' : 'span', href ? { href, class: `ov-shipped-subject${e.major ? ' ov-shipped-major' : ''}`, rel: 'noopener', target: '_blank', title: e.subject } : { class: 'ov-shipped-subject' }, e.subject);++ li.appendChild(a);++ if (e.deployed_at) li.appendChild(timeEl(e.deployed_at));++ ul.appendChild(li);++ }++ node.appendChild(ul);++ const links = el('div', { class: 'ov-shipped-links' });++ const more = localHref(o.more);++ if (more) links.appendChild(el('a', { href: more }, 'All updates →'));++ const post = data.latest_post && safeUrl(data.latest_post.url) ? data.latest_post : null;++ if (post) links.appendChild(el('a', { class: 'ov-shipped-notes', href: post.url }, `Patch notes: ${String(post.title || 'latest').replace(/^Patch notes:\s*/, '')} →`));++ if (links.childNodes.length) node.appendChild(links);++ node.hidden = false;++ tick();++ return node;++ }++ async function list(target, opts) {++ const o = { ...(opts || {}) };++ const node = typeof target === 'string' ? document.querySelector(target) : target;++ if (!node) return null;++ o.service = resolveService(o.service);++ style();++ return render(node, await feed({ service: o.service, limit: clamp(o.limit, 1, 20, 5), api: o.api }), o);++ }++++ // ── log: the updates page ───────────────────────────────────++ function dayOf(iso) { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); }++ function entryRow(e, showSite) {++ const row = el('div', { class: 'ov-shipped-entry' });++ const href = safeUrl(e.url);++ row.appendChild(el(href ? 'a' : 'span', href ? { class: 'ov-shipped-hash', href, target: '_blank', rel: 'noopener', title: 'The change on GitHub' } : { class: 'ov-shipped-hash' }, e.short || String(e.sha || '').slice(0, 7)));++ if (showSite) row.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));++ row.appendChild(el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}` }, e.subject));++ const meta = el('span', { class: 'ov-shipped-meta' });++ const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });++ meta.textContent = `${e.author ? `${e.author} · ` : ''}${time}`;++ row.appendChild(meta);++ return row;++ }++ /** Append entries under day headings, continuing the last day already shown (exported for tests). */++ function appendDays(box, entries, showSite) {++ let day = box.lastElementChild && box.lastElementChild.classList.contains('ov-shipped-day') ? box.lastElementChild : null;++ for (const e of entries) {++ const d = dayOf(e.deployed_at);++ if (!day || day.getAttribute('data-day') !== d) {++ day = el('section', { class: 'ov-shipped-day', 'data-day': d });++ day.appendChild(el('h3', {}, d));++ day.appendChild(el('div', { class: 'ov-shipped-entries' }));++ box.appendChild(day);++ }++ day.lastElementChild.appendChild(entryRow(e, showSite));++ }++ }++ async function log(target, opts) {++ const o = { ...(opts || {}) };++ const node = typeof target === 'string' ? document.querySelector(target) : target;++ if (!node) return null;++ style();++ const own = resolveService(o.service);++ const pageSize = clamp(o.limit, 10, 100, 50);++ const params = typeof location !== 'undefined' ? new URLSearchParams(location.search) : new URLSearchParams();++ // ?site= picks the site on the whole-network page (and "all" on a site's own page).++ let current = own;++ const asked = params.get('site');++ if (asked === 'all') current = null;++ else if (asked && serviceFor(asked)) current = serviceFor(asked);++++ node.textContent = '';++ node.classList.add('ov-shipped-log');++ const bar = el('div', { class: 'ov-shipped-bar', role: 'group', 'aria-label': 'Which site' });++ const posts = el('aside', { class: 'ov-shipped-posts', hidden: '' });++ const days = el('div', { class: 'ov-shipped-days' });++ days.style.cssText = 'display:flex;flex-direction:column;gap:24px;min-width:0';++ const more = el('button', { type: 'button', class: 'ov-shipped-more', hidden: '' }, 'Load more');++ node.append(bar, posts, days, more);++ let next = null;++ let seq = 0;++++ function setUrl(svc) {++ if (o.syncUrl === false || typeof history === 'undefined' || !history.replaceState) return;++ const u = new URL(location.href);++ if (own ? svc === own : !svc) u.searchParams.delete('site'); else u.searchParams.set('site', svc || 'all');++ history.replaceState(history.state, '', u.pathname + u.search + u.hash);++ }++ function chip(label, svc) {++ const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String((svc || null) === (current || null)) }, label);++ b.addEventListener('click', () => { current = svc || null; setUrl(current); load(true); });++ return b;++ }++ function drawBar(sites) {++ bar.textContent = '';++ if (own) { bar.append(chip(siteName(own), own), chip('All of OpenVibe', null)); return; }++ bar.append(chip('Everything', null));++ for (const s of (sites || []).slice(0, 30)) bar.appendChild(chip(siteName(s.service), s.service));++ }++ function drawPosts(list) {++ posts.textContent = '';++ const good = (list || []).filter((p) => safeUrl(p.url)).slice(0, 5);++ if (!good.length || o.posts === false) { posts.hidden = true; return; }++ posts.appendChild(el('h3', {}, 'Patch notes'));++ const ul = el('ul');++ for (const p of good) {++ const li = el('li');++ li.appendChild(el('a', { href: p.url }, p.title || 'Patch notes'));++ if (p.published_at) li.appendChild(el('small', {}, `${new Date(p.published_at).toLocaleDateString()}${p.entries ? ` · ${p.entries} changes` : ''}`));++ ul.appendChild(li);++ }++ posts.appendChild(ul);++ posts.hidden = false;++ }++ let sitesSeen = null;++ async function load(reset) {++ const mine = ++seq;++ if (reset) {++ next = null;++ days.textContent = '';++ days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Loading…'));++ for (const b of bar.querySelectorAll('.ov-shipped-chip')) b.setAttribute('aria-pressed', 'false');++ }++ more.disabled = true;++ const data = await feed({ service: current, limit: pageSize, before: reset ? null : next, api: o.api });++ if (mine !== seq) return;++ if (reset) {++ days.textContent = '';++ if (data && data.sites) sitesSeen = data.sites;++ if (!own && !sitesSeen) {++ const all = await feed({ service: null, limit: 1, api: o.api });++ sitesSeen = all && all.sites;++ }++ drawBar(sitesSeen);++ for (const b of bar.querySelectorAll('.ov-shipped-chip')) if (b.textContent === (current ? siteName(current) : (own ? 'All of OpenVibe' : 'Everything'))) b.setAttribute('aria-pressed', 'true');++ drawPosts(data && data.posts);++ }++ if (!data) {++ if (reset) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'The update history could not be loaded just now.'));++ more.hidden = true;++ return;++ }++ const entries = Array.isArray(data.entries) ? data.entries : [];++ if (reset && !entries.length) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Nothing has shipped here yet.'));++ appendDays(days, entries, !current);++ next = data.next || null;++ more.hidden = !next;++ more.disabled = false;++ tick();++ }++ more.addEventListener('click', () => load(false));++ await load(true);++ return node;++ }++++ // ── markup ──────────────────────────────────────────────────++ const VIEWS = { latest, list, log };++ /** Mount every [data-ov-shipped] element under `root` once. */++ function scan(rootEl) {++ const r = rootEl || (typeof document !== 'undefined' ? document : null);++ if (!r || !r.querySelectorAll) return [];++ const out = [];++ for (const n of r.querySelectorAll('[data-ov-shipped]')) {++ if (n.hasAttribute('data-ov-shipped-mounted')) continue;++ const view = VIEWS[n.getAttribute('data-ov-shipped')];++ if (!view) continue;++ n.setAttribute('data-ov-shipped-mounted', '');++ const d = n.dataset || {};++ out.push(view(n, { service: d.service || null, limit: d.limit, title: d.title, href: d.href, more: d.more, api: d.api, posts: d.posts === 'off' ? false : undefined }).catch(() => null));++ }++ return out;++ }++++ return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, serviceFor, ago, API, SITE_NAMES };++}));nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -0,0 +1,352 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — Theme Loader (Browser)++// Synchronous theme application from cookie/localStorage.++// Load this BEFORE navbar.js to prevent FOUC.++// Sets CSS custom properties on <html> from the user's theme.++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ const COOKIE_NAME = 'ov_theme_id';++ const STORAGE_KEY = 'ov_theme';++ // The user's theme is owned centrally by openvibe.network, so EVERY app (openvibe.live,++ // openvibe.tools + subdomains, openvibe.games, openvibe.media) fetches it from there —++ // that's what makes the theme chosen at openvibe.network/themes sync everywhere.++ // Apps may override via init({apiBase}).++ const DEFAULT_API_BASE = 'https://openvibe.network';++ let _config = { apiBase: DEFAULT_API_BASE };++++ // ── Condensed built-in theme variable maps ───────────────++ // Kept in sync with packages/openvibe-shared/builtin-themes.js++ // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand++ const THEMES = {++ 'vibe': { '--bg-primary':'#0a0f1c','--bg-secondary':'#101828','--bg-tertiary':'#172238','--bg-card':'#131c2e','--bg-hover':'#1c2a44','--bg-input':'#0d1424','--text-primary':'#e6edf7','--text-secondary':'#96a7c2','--text-muted':'#7386a3','--accent':'#3b82f6','--accent-light':'#60a5fa','--accent-dark':'#1d4ed8','--border':'#1f2d47','--border-light':'#2c3d5c','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#3472d8','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'59,130,246','--accent-glow':'rgba(59,130,246,0.35)','--color-scheme':'dark' },++ 'violet-hour': { '--bg-primary':'#0d0d0f','--bg-secondary':'#16161a','--bg-tertiary':'#1e1e24','--bg-card':'#1a1a20','--bg-hover':'#242430','--bg-input':'#12121a','--text-primary':'#e8e6e3','--text-secondary':'#9a9a9a','--text-muted':'#838282','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#2a2a32','--border-light':'#3a3a44','--live-red':'#e74c3c','--success':'#2ecc71','--warning':'#f39c12','--danger':'#e74c3c','--info':'#3498db','--shadow':'0 2px 12px rgba(0,0,0,0.4)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#8558ec','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'139,92,246','--accent-glow':'rgba(139,92,246,0.35)','--color-scheme':'dark' },++ 'midnight': { '--bg-primary':'#0a0e17','--bg-secondary':'#111827','--bg-tertiary':'#1a2235','--bg-card':'#151d2e','--bg-hover':'#1e2a42','--bg-input':'#0d1220','--text-primary':'#e2e8f0','--text-secondary':'#94a3b8','--text-muted':'#78879b','--accent':'#6366f1','--accent-light':'#818cf8','--accent-dark':'#4f46e5','--border':'#1e293b','--border-light':'#334155','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#6164ec','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'99,102,241','--accent-glow':'rgba(99,102,241,0.35)','--color-scheme':'dark' },++ 'forest': { '--bg-primary':'#0a120a','--bg-secondary':'#121e12','--bg-tertiary':'#1a2a1a','--bg-card':'#152015','--bg-hover':'#1e3420','--bg-input':'#0d160d','--text-primary':'#d4e8d4','--text-secondary':'#8aaa8a','--text-muted':'#708e70','--accent':'#4ade80','--accent-light':'#86efac','--accent-dark':'#22c55e','--border':'#1a3a1a','--border-light':'#2a4a2a','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#4ade80','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'74,222,128','--accent-glow':'rgba(74,222,128,0.35)','--color-scheme':'dark' },++ 'neon-tokyo': { '--bg-primary':'#0a0a0f','--bg-secondary':'#12101a','--bg-tertiary':'#1a1625','--bg-card':'#15111f','--bg-hover':'#221a32','--bg-input':'#0d0b14','--text-primary':'#f0e6ff','--text-secondary':'#b4a0d0','--text-muted':'#87799c','--accent':'#ff006e','--accent-light':'#ff4d94','--accent-dark':'#c9005a','--border':'#2a1a3a','--border-light':'#3a2a4a','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#e60063','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'255,0,110','--accent-glow':'rgba(255,0,110,0.35)','--color-scheme':'dark' },++ 'dracula': { '--bg-primary':'#282a36','--bg-secondary':'#1e1f29','--bg-tertiary':'#343746','--bg-card':'#2c2e3c','--bg-hover':'#3a3d50','--bg-input':'#21222c','--text-primary':'#f8f8f2','--text-secondary':'#bfbfb2','--text-muted':'#8995b8','--accent':'#bd93f9','--accent-light':'#d4b8ff','--accent-dark':'#9b6ddb','--border':'#44475a','--border-light':'#565970','--live-red':'#ff5555','--success':'#50fa7b','--warning':'#f1fa8c','--danger':'#ff5555','--info':'#8be9fd','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#bd93f9','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'189,147,249','--accent-glow':'rgba(189,147,249,0.35)','--color-scheme':'dark' },++ 'monokai': { '--bg-primary':'#272822','--bg-secondary':'#1e1f1c','--bg-tertiary':'#3e3d32','--bg-card':'#2d2e27','--bg-hover':'#49483e','--bg-input':'#22231e','--text-primary':'#f8f8f2','--text-secondary':'#cfcfc2','--text-muted':'#979484','--accent':'#a6e22e','--accent-light':'#c4ff50','--accent-dark':'#7dac1c','--border':'#49483e','--border-light':'#5b5a50','--live-red':'#ef4444','--success':'#22c55e','--warning':'#e6db74','--danger':'#f92672','--info':'#66d9ef','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#a6e22e','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'166,226,46','--accent-glow':'rgba(166,226,46,0.35)','--color-scheme':'dark' },++ 'ocean-deep': { '--bg-primary':'#0a1215','--bg-secondary':'#0f1a1f','--bg-tertiary':'#162228','--bg-card':'#121c22','--bg-hover':'#1a2a32','--bg-input':'#0c1418','--text-primary':'#d0e8ef','--text-secondary':'#7aa8ba','--text-muted':'#658896','--accent':'#00bcd4','--accent-light':'#4dd0e1','--accent-dark':'#0097a7','--border':'#1a3040','--border-light':'#2a4050','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#00bcd4','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'0,188,212','--accent-glow':'rgba(0,188,212,0.35)','--color-scheme':'dark' },++ 'sunset': { '--bg-primary':'#120a0a','--bg-secondary':'#1a1010','--bg-tertiary':'#241616','--bg-card':'#1e1212','--bg-hover':'#2e1a1a','--bg-input':'#140c0c','--text-primary':'#ffe8e0','--text-secondary':'#c49080','--text-muted':'#977970','--accent':'#ff6b35','--accent-light':'#ff8f5e','--accent-dark':'#d04a1a','--border':'#3a2020','--border-light':'#4a3030','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#ff6b35','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,107,53','--accent-glow':'rgba(255,107,53,0.35)','--color-scheme':'dark' },++ 'arctic': { '--bg-primary':'#0b1620','--bg-secondary':'#0f1e2b','--bg-tertiary':'#163040','--bg-card':'#122534','--bg-hover':'#1a3a4c','--bg-input':'#0c1a26','--text-primary':'#eaf6ff','--text-secondary':'#9ec3d8','--text-muted':'#7ea3ba','--accent':'#22d3ee','--accent-light':'#a5f3fc','--accent-dark':'#0891b2','--border':'#1e3f52','--border-light':'#2a5268','--live-red':'#ef4444','--success':'#34d399','--warning':'#f59e0b','--danger':'#ef4444','--info':'#7dd3fc','--shadow':'0 2px 12px rgba(2,12,20,0.5)','--shadow-lg':'0 8px 32px rgba(2,12,20,0.65)','--accent-strong':'#22d3ee','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'34,211,238','--accent-glow':'rgba(34,211,238,0.35)','--color-scheme':'dark' },++ 'ember': { '--bg-primary':'#120808','--bg-secondary':'#1a0e0e','--bg-tertiary':'#241414','--bg-card':'#1e1010','--bg-hover':'#2e1818','--bg-input':'#140a0a','--text-primary':'#f0dada','--text-secondary':'#b0808a','--text-muted':'#95787c','--accent':'#ef4444','--accent-light':'#f87171','--accent-dark':'#b91c1c','--border':'#3a1a20','--border-light':'#4a2a30','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#d73d3d','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'239,68,68','--accent-glow':'rgba(239,68,68,0.35)','--color-scheme':'dark' },++ 'vapor': { '--bg-primary':'#0e0a14','--bg-secondary':'#14101c','--bg-tertiary':'#1c1628','--bg-card':'#181220','--bg-hover':'#241a34','--bg-input':'#100c16','--text-primary':'#f0e0ff','--text-secondary':'#c090e0','--text-muted':'#8f73b7','--accent':'#e040fb','--accent-light':'#ea80fc','--accent-dark':'#aa00d4','--border':'#2a1a40','--border-light':'#3a2a50','--live-red':'#ef4444','--success':'#69f0ae','--warning':'#f59e0b','--danger':'#ef4444','--info':'#00e5ff','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#bc36d3','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'224,64,251','--accent-glow':'rgba(224,64,251,0.35)','--color-scheme':'dark' },++ 'slate': { '--bg-primary':'#0f1118','--bg-secondary':'#161820','--bg-tertiary':'#1e2028','--bg-card':'#1a1c24','--bg-hover':'#24262e','--bg-input':'#12141c','--text-primary':'#e4e6ea','--text-secondary':'#a0a4b0','--text-muted':'#828690','--accent':'#a78bfa','--accent-light':'#c4b5fd','--accent-dark':'#7c3aed','--border':'#2a2c38','--border-light':'#3a3c48','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#a78bfa','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'167,139,250','--accent-glow':'rgba(167,139,250,0.35)','--color-scheme':'dark' },++ 'matrix': { '--bg-primary':'#000000','--bg-secondary':'#050a05','--bg-tertiary':'#0a140a','--bg-card':'#071007','--bg-hover':'#0e1e0e','--bg-input':'#030803','--text-primary':'#00ff41','--text-secondary':'#00b030','--text-muted':'#008f25','--accent':'#00ff41','--accent-light':'#66ff8a','--accent-dark':'#00aa2a','--border':'#0a2a0a','--border-light':'#144014','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#00ff41','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'0,255,65','--accent-glow':'rgba(0,255,65,0.35)','--color-scheme':'dark' },++ 'nord': { '--bg-primary':'#2e3440','--bg-secondary':'#272c36','--bg-tertiary':'#3b4252','--bg-card':'#333a48','--bg-hover':'#434c5e','--bg-input':'#2a303c','--text-primary':'#eceff4','--text-secondary':'#d8dee9','--text-muted':'#9ba5b8','--accent':'#88c0d0','--accent-light':'#8fbcbb','--accent-dark':'#5e81ac','--border':'#3b4252','--border-light':'#4c566a','--live-red':'#ef4444','--success':'#a3be8c','--warning':'#ebcb8b','--danger':'#bf616a','--info':'#81a1c1','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#88c0d0','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'136,192,208','--accent-glow':'rgba(136,192,208,0.35)','--color-scheme':'dark' },++ 'gruvbox-dark': { '--bg-primary':'#1d2021','--bg-secondary':'#282828','--bg-tertiary':'#3c3836','--bg-card':'#32302f','--bg-hover':'#504945','--bg-input':'#242424','--text-primary':'#ebdbb2','--text-secondary':'#d5c4a1','--text-muted':'#a69682','--accent':'#fabd2f','--accent-light':'#fce566','--accent-dark':'#d79921','--border':'#3c3836','--border-light':'#504945','--live-red':'#ef4444','--success':'#b8bb26','--warning':'#fe8019','--danger':'#fb4934','--info':'#83a598','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#fabd2f','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'250,189,47','--accent-glow':'rgba(250,189,47,0.35)','--color-scheme':'dark' },++ 'abyss': { '--bg-primary':'#000000','--bg-secondary':'#080808','--bg-tertiary':'#111111','--bg-card':'#0a0a0a','--bg-hover':'#181818','--bg-input':'#050505','--text-primary':'#cccccc','--text-secondary':'#888888','--text-muted':'#7a7a7a','--accent':'#ffffff','--accent-light':'#ffffff','--accent-dark':'#aaaaaa','--border':'#1a1a1a','--border-light':'#252525','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#ffffff','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,255,255','--accent-glow':'rgba(255,255,255,0.35)','--color-scheme':'dark' },++ 'copper': { '--bg-primary':'#110d09','--bg-secondary':'#1a140e','--bg-tertiary':'#231c14','--bg-card':'#1e1710','--bg-hover':'#2c2218','--bg-input':'#13100b','--text-primary':'#e8ddd0','--text-secondary':'#b49a80','--text-muted':'#8e7f6a','--accent':'#cd7f32','--accent-light':'#daa060','--accent-dark':'#a06020','--border':'#2a2016','--border-light':'#3a3020','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#a46628','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'205,127,50','--accent-glow':'rgba(205,127,50,0.35)','--color-scheme':'dark' },++ 'sakura-night': { '--bg-primary':'#0e0a14','--bg-secondary':'#150f1c','--bg-tertiary':'#1c1526','--bg-card':'#181020','--bg-hover':'#221830','--bg-input':'#100c16','--text-primary':'#f0e0f0','--text-secondary':'#c0a0c0','--text-muted':'#927592','--accent':'#f472b6','--accent-light':'#f9a8d4','--accent-dark':'#db2777','--border':'#2a1a30','--border-light':'#3a2a40','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#f472b6','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'244,114,182','--accent-glow':'rgba(244,114,182,0.35)','--color-scheme':'dark' },++ 'hacker': { '--bg-primary':'#000000','--bg-secondary':'#0a0800','--bg-tertiary':'#141000','--bg-card':'#0e0c00','--bg-hover':'#1c1800','--bg-input':'#060400','--text-primary':'#ffb000','--text-secondary':'#cc8d00','--text-muted':'#a37100','--accent':'#ffb000','--accent-light':'#ffd050','--accent-dark':'#cc8d00','--border':'#1a1400','--border-light':'#2a2200','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#ffb000','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,176,0','--accent-glow':'rgba(255,176,0,0.35)','--color-scheme':'dark' },++ 'daylight': { '--bg-primary':'#ffffff','--bg-secondary':'#f7f7f8','--bg-tertiary':'#eeeef0','--bg-card':'#ffffff','--bg-hover':'#f0f0f2','--bg-input':'#f5f5f7','--text-primary':'#1a1a2e','--text-secondary':'#4a4a6a','--text-muted':'#747489','--accent':'#8756f3','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#e0e0e6','--border-light':'#d0d0d8','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.08)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.12)','--accent-strong':'#8756f3','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'135,86,243','--accent-glow':'rgba(135,86,243,0.35)','--color-scheme':'light' },++ 'paper': { '--bg-primary':'#faf8f5','--bg-secondary':'#f0ece6','--bg-tertiary':'#e8e2d8','--bg-card':'#faf8f5','--bg-hover':'#f0ece6','--bg-input':'#f5f2ed','--text-primary':'#2c2416','--text-secondary':'#5a4e3e','--text-muted':'#7b7061','--accent':'#8b6914','--accent-light':'#b08a30','--accent-dark':'#6a5010','--border':'#ddd6c8','--border-light':'#ccc4b0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)','--accent-strong':'#8b6914','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'139,105,20','--accent-glow':'rgba(139,105,20,0.35)','--color-scheme':'light' },++ 'cloud': { '--bg-primary':'#f0f5ff','--bg-secondary':'#e8eef8','--bg-tertiary':'#dde5f0','--bg-card':'#f5f8ff','--bg-hover':'#e4ecf8','--bg-input':'#edf2fa','--text-primary':'#1a2040','--text-secondary':'#4a5070','--text-muted':'#6b718c','--accent':'#2867ed','--accent-light':'#60a5fa','--accent-dark':'#2563eb','--border':'#d0d8ea','--border-light':'#bcc8e0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,20,60,0.06)','--shadow-lg':'0 8px 32px rgba(0,20,60,0.10)','--accent-strong':'#2867ed','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'40,103,237','--accent-glow':'rgba(40,103,237,0.35)','--color-scheme':'light' },++ 'meadow': { '--bg-primary':'#f2f8f0','--bg-secondary':'#e8f0e4','--bg-tertiary':'#dce8d6','--bg-card':'#f5faf2','--bg-hover':'#e4f0de','--bg-input':'#edf5ea','--text-primary':'#1a2e16','--text-secondary':'#3a5a32','--text-muted':'#5d7a54','--accent':'#15823e','--accent-light':'#22c55e','--accent-dark':'#15803d','--border':'#c8dcc0','--border-light':'#b0ccaa','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,40,0,0.10)','--accent-strong':'#15823e','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'21,130,62','--accent-glow':'rgba(21,130,62,0.35)','--color-scheme':'light' },++ 'peach': { '--bg-primary':'#fff5f0','--bg-secondary':'#fbeae2','--bg-tertiary':'#f5ddd2','--bg-card':'#fff7f2','--bg-hover':'#fce8dc','--bg-input':'#fdf0ea','--text-primary':'#2e1a14','--text-secondary':'#6a4438','--text-muted':'#8f6a5c','--accent':'#c04808','--accent-light':'#fb923c','--accent-dark':'#ea580c','--border':'#f0d0c0','--border-light':'#e4bfab','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,20,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,20,0,0.10)','--accent-strong':'#c04808','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'192,72,8','--accent-glow':'rgba(192,72,8,0.35)','--color-scheme':'light' },++ 'lavender': { '--bg-primary':'#f8f5ff','--bg-secondary':'#f0eaf8','--bg-tertiary':'#e6ddf0','--bg-card':'#faf7ff','--bg-hover':'#eee6f8','--bg-input':'#f4f0fa','--text-primary':'#1e1430','--text-secondary':'#4a3a60','--text-muted':'#796a92','--accent':'#844cf2','--accent-light':'#a78bfa','--accent-dark':'#7c3aed','--border':'#ddd0ee','--border-light':'#ccc0dd','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(40,0,60,0.06)','--shadow-lg':'0 8px 32px rgba(40,0,60,0.10)','--accent-strong':'#844cf2','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'132,76,242','--accent-glow':'rgba(132,76,242,0.35)','--color-scheme':'light' },++ 'gruvbox-light': { '--bg-primary':'#fbf1c7','--bg-secondary':'#f2e5bc','--bg-tertiary':'#ebdbb2','--bg-card':'#fbf1c7','--bg-hover':'#f2e5bc','--bg-input':'#f5ecc4','--text-primary':'#3c3836','--text-secondary':'#504945','--text-muted':'#766b60','--accent':'#be4707','--accent-light':'#fe8019','--accent-dark':'#af3a03','--border':'#d5c4a1','--border-light':'#bdae93','--live-red':'#ef4444','--success':'#98971a','--warning':'#d79921','--danger':'#cc241d','--info':'#458588','--shadow':'0 2px 12px rgba(40,30,0,0.08)','--shadow-lg':'0 8px 32px rgba(40,30,0,0.12)','--accent-strong':'#be4707','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'190,71,7','--accent-glow':'rgba(190,71,7,0.35)','--color-scheme':'light' },++ 'snow': { '--bg-primary':'#ffffff','--bg-secondary':'#f8fafc','--bg-tertiary':'#f1f5f9','--bg-card':'#ffffff','--bg-hover':'#f1f5f9','--bg-input':'#f8fafc','--text-primary':'#0f172a','--text-secondary':'#334155','--text-muted':'#69768b','--accent':'#027cbb','--accent-light':'#38bdf8','--accent-dark':'#0284c7','--border':'#e2e8f0','--border-light':'#cbd5e1','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.05)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.08)','--accent-strong':'#027cbb','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'2,124,187','--accent-glow':'rgba(2,124,187,0.35)','--color-scheme':'light' },++ 'sand': { '--bg-primary':'#f8f4ef','--bg-secondary':'#f0e8de','--bg-tertiary':'#e8ddd0','--bg-card':'#faf6f0','--bg-hover':'#f0e8de','--bg-input':'#f5f0e8','--text-primary':'#2e2418','--text-secondary':'#5a4e3e','--text-muted':'#7a6e5f','--accent':'#8c6707','--accent-light':'#daa520','--accent-dark':'#8b6508','--border':'#ddd0be','--border-light':'#ccc0aa','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)','--accent-strong':'#8c6707','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'140,103,7','--accent-glow':'rgba(140,103,7,0.35)','--color-scheme':'light' },++ 'rose': { '--bg-primary':'#fff5f7','--bg-secondary':'#fce7ec','--bg-tertiary':'#f5d5de','--bg-card':'#fff7f9','--bg-hover':'#fce0e8','--bg-input':'#fdeef2','--text-primary':'#2e1420','--text-secondary':'#6a3850','--text-muted':'#96617e','--accent':'#de1c47','--accent-light':'#f43f5e','--accent-dark':'#be123c','--border':'#f0c8d4','--border-light':'#e4b0c0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,0,20,0.06)','--shadow-lg':'0 8px 32px rgba(60,0,20,0.10)','--accent-strong':'#de1c47','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'222,28,71','--accent-glow':'rgba(222,28,71,0.35)','--color-scheme':'light' },++ 'solarized-dark': { '--bg-primary':'#002b36','--bg-secondary':'#073642','--bg-tertiary':'#0a3f4e','--bg-card':'#053542','--bg-hover':'#0d4a58','--bg-input':'#01303d','--text-primary':'#fdf6e3','--text-secondary':'#93a1a1','--text-muted':'#909c9a','--accent':'#b58900','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#0a4050','--border-light':'#105060','--live-red':'#ef4444','--success':'#859900','--warning':'#cb4b16','--danger':'#dc322f','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#947000','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'181,137,0','--accent-glow':'rgba(181,137,0,0.35)','--color-scheme':'dark' },++ 'solarized-light': { '--bg-primary':'#fdf6e3','--bg-secondary':'#eee8d5','--bg-tertiary':'#e4ddc6','--bg-card':'#fdf6e3','--bg-hover':'#eee8d5','--bg-input':'#f5eedb','--text-primary':'#002b36','--text-secondary':'#586e75','--text-muted':'#5b7478','--accent':'#8d6c00','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#d6ceb5','--border-light':'#c8c0a5','--live-red':'#ef4444','--success':'#859900','--warning':'#cb4b16','--danger':'#dc322f','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,30,40,0.08)','--shadow-lg':'0 8px 32px rgba(0,30,40,0.12)','--accent-strong':'#8d6c00','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'141,108,0','--accent-glow':'rgba(141,108,0,0.35)','--color-scheme':'light' },++ 'catppuccin-mocha': { '--bg-primary':'#1e1e2e','--bg-secondary':'#181825','--bg-tertiary':'#313244','--bg-card':'#1e1e2e','--bg-hover':'#313244','--bg-input':'#1a1a2c','--text-primary':'#cdd6f4','--text-secondary':'#bac2de','--text-muted':'#81869e','--accent':'#cba6f7','--accent-light':'#dcc0ff','--accent-dark':'#b07de0','--border':'#313244','--border-light':'#45475a','--live-red':'#f38ba8','--success':'#a6e3a1','--warning':'#fab387','--danger':'#f38ba8','--info':'#89b4fa','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#cba6f7','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'203,166,247','--accent-glow':'rgba(203,166,247,0.35)','--color-scheme':'dark' },++ 'catppuccin-latte': { '--bg-primary':'#eff1f5','--bg-secondary':'#e6e9ef','--bg-tertiary':'#dce0e8','--bg-card':'#eff1f5','--bg-hover':'#dce0e8','--bg-input':'#e8ebf0','--text-primary':'#4c4f69','--text-secondary':'#5c5f77','--text-muted':'#696c83','--accent':'#8839ef','--accent-light':'#a05cff','--accent-dark':'#7028d4','--border':'#ccd0da','--border-light':'#bcc0cc','--live-red':'#d20f39','--success':'#40a02b','--warning':'#fe640b','--danger':'#d20f39','--info':'#1e66f5','--shadow':'0 2px 12px rgba(0,0,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.10)','--accent-strong':'#8839ef','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'136,57,239','--accent-glow':'rgba(136,57,239,0.35)','--color-scheme':'light' },++ 'high-contrast': { '--bg-primary':'#000000','--bg-secondary':'#0a0a0a','--bg-tertiary':'#1a1a1a','--bg-card':'#0f0f0f','--bg-hover':'#222222','--bg-input':'#0a0a0a','--text-primary':'#ffffff','--text-secondary':'#f0f0f0','--text-muted':'#aaaaaa','--accent':'#ffd700','--accent-light':'#ffed4a','--accent-dark':'#ccac00','--border':'#444444','--border-light':'#666666','--live-red':'#ff0000','--success':'#00ff00','--warning':'#ffff00','--danger':'#ff0000','--info':'#00ffff','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#ffd700','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,215,0','--accent-glow':'rgba(255,215,0,0.35)','--color-scheme':'dark' },++ };++ // THEMES:END++++ function getCookie(name) {++ const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));++ return match ? decodeURIComponent(match[1]) : null;++ }++++ function setCookie(name, value, days) {++ const maxAge = days * 24 * 60 * 60;++ const encoded = encodeURIComponent(value);++ const host = (typeof location !== 'undefined' && location.hostname) || '';++ // Cookie policy (CONTRACTS.md): a Domain=.openvibe.tools cookie is set ONLY++ // when actually running on openvibe.tools or one of its subdomains, so the++ // theme follows the user across the tool satellites. Everywhere else++ // (openvibe.network, openvibe.live, openvibe.games, localhost, ...) the++ // cookie is host-only — NO Domain attribute.++ if (host && (host.endsWith('.openvibe.tools') || host === 'openvibe.tools')) {++ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;++ } else {++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';++ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};SameSite=Lax${secure}`;++ }++ }++++ /**++ * Apply a theme's CSS variables to document.documentElement.++ * If themeId is not found in the built-in map, does nothing.++ */++ function applyById(themeId) {++ const vars = THEMES[themeId];++ if (!vars) return false;++ applyVars(vars);++ return true;++ }++++ /**++ * Apply arbitrary CSS variables object to <html>.++ */++ // Every token any built-in theme sets. Applying a theme clears the ones it does not set, so a++ // theme that names no --success cannot inherit the previous theme's --success on <html>.++ const KNOWN_PROPS = (function () { const out = {}; for (const id in THEMES) for (const k in THEMES[id]) out[k] = true; return Object.keys(out); })();++ function applyVars(vars) {++ if (!vars || typeof vars !== 'object') return;++ const el = document.documentElement;++ for (let i = 0; i < KNOWN_PROPS.length; i++) if (!(KNOWN_PROPS[i] in vars)) el.style.removeProperty(KNOWN_PROPS[i]);++ for (const [prop, val] of Object.entries(vars)) {++ if (prop.startsWith('--')) el.style.setProperty(prop, val);++ }++ // Native controls, scrollbars and form fields follow the theme's light/dark mode.++ const scheme = vars['--color-scheme'];++ if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }++ // The inline critical style paints a fixed dark canvas before any stylesheet; keep it in step with the theme.++ if (vars['--bg-primary']) el.style.background = vars['--bg-primary'];++ if (vars['--text-primary']) el.style.color = vars['--text-primary'];++ syncBrowserUi();++ }++++ // ── The browser's own UI (address bar, theme-color) follows the theme ─────────────────────────────────────────────────++ // The tab icon and the browser/OS toolbar colour are repainted from the theme that was just++ // applied, so a violet, light or custom theme gets a matching favicon and address bar. Runs on++ // every apply: first paint, a change on this page, a change in another tab, a server sync.++ // favicon: only <link rel="icon" data-ov-icon="<site>"> (written by app-icon.headTags) is touched++ // event: window 'ov:theme' { accent, accentLight, bg, mode } for anything else that wants to follow++ const DOTS = { live: '#ef4444', games: '#facc15' };++ const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;++ function readColor(name, fallback) {++ try { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return COLOR_RE.test(v) ? v : fallback; } catch { return fallback; }++ }++ function faviconSvg(site, accent, light, bg) {++ const e = encodeURIComponent;++ return 'data:image/svg+xml,' + e('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs><linearGradient id="r" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + light + '"/><stop offset="1" stop-color="' + accent + '"/></linearGradient></defs>'++ + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(1.22)"><circle r="18" fill="none" stroke="url(#r)" stroke-width="4" opacity=".38"/>'++ + '<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#r)" stroke-width="4.4" stroke-linecap="round"/><path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="' + (document.documentElement.getAttribute('data-theme-mode') === 'light' ? accent : '#fff') + '" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>'++ + '<circle cx="17.4" cy="4.6" r="3.2" fill="' + (DOTS[site] || light) + '"/></g></svg>');++ }++ let _browserUiKey = '';++ function syncBrowserUi() {++ if (typeof document === 'undefined' || !document.head) return;++ const accent = readColor('--accent', '#3b82f6'), light = readColor('--accent-light', accent), bg = readColor('--bg-secondary', readColor('--bg-primary', '#0a0f1c'));++ const mode = document.documentElement.getAttribute('data-theme-mode') || 'dark';++ const key = [accent, light, bg, mode].join('|'); if (key === _browserUiKey) return; _browserUiKey = key;++ try {++ let meta = document.querySelector('meta[name="theme-color"]');++ if (!meta) { meta = document.createElement('meta'); meta.name = 'theme-color'; document.head.appendChild(meta); }++ meta.setAttribute('content', bg);++ const link = document.querySelector('link[rel~="icon"][data-ov-icon]');++ if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), accent, light, readColor('--bg-primary', '#0a0f1c')); }++ } catch { /* cosmetic */ }++ try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }++ }++++ /**++ * Resolve the active theme from (in order):++ * 1. localStorage 'ov_theme' (has id + variables — instant)++ * 2. Cookie 'ov_theme_id' (just slug — look up in built-in map)++ * Falls back to 'vibe'.++ */++ function resolveAndApply() {++ // 1. Try localStorage (same-origin cache with full variables)++ try {++ const raw = localStorage.getItem(STORAGE_KEY);++ if (raw) {++ const saved = JSON.parse(raw);++ if (saved && saved.variables && typeof saved.variables === 'object') {++ applyVars(saved.variables);++ const cachedId = saved.id || saved.slug || 'vibe';++ try { document.documentElement.setAttribute('data-theme', cachedId); } catch { /* no DOM */ }++ return cachedId;++ }++ if (saved && (saved.id || saved.slug)) {++ const id = saved.id || saved.slug;++ if (applyById(id)) return id;++ }++ }++ } catch { /* corrupt localStorage */ }++++ // 2. Try cross-domain cookie++ const cookieId = getCookie(COOKIE_NAME);++ if (cookieId && applyById(cookieId)) {++ // Cache the full variable set so the next load paints from localStorage instantly.++ cacheLocally(cookieId, THEMES[cookieId]);++ return cookieId;++ }++++ // 3. Default — vibe (already the CSS fallback values, but apply explicitly)++ applyById('vibe');++ return 'vibe';++ }++++ function authToken() {++ return getCookie('ov_token') ||++ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token'))) ||++ null;++ }++++ /**++ * Write the resolved theme to every local cache. Always stores the FULL variable set —++ * including for community/custom themes that aren't in the built-in map — so the next++ * page load can paint the right colours synchronously with no network call and no flash.++ */++ function cacheLocally(themeId, vars) {++ try {++ localStorage.setItem(STORAGE_KEY, JSON.stringify({++ id: themeId, slug: themeId, variables: vars, at: Date.now(),++ }));++ } catch { /* quota */ }++ setCookie(COOKIE_NAME, themeId, 365);++ try { document.documentElement.setAttribute('data-theme', themeId); } catch { /* no DOM */ }++ }++++ /**++ * Save theme choice locally AND to the user's openvibe.network account, so the choice++ * follows them to every OpenVibe site. Pass opts.custom for per-user variable overrides;++ * opts.sync === false keeps it device-local.++ */++ function save(themeId, variables, opts) {++ const custom = (opts && opts.custom) || null;++ const base = variables || THEMES[themeId] || {};++ const vars = custom ? Object.assign({}, base, custom) : base;++ if (!vars || !Object.keys(vars).length) return;++ cacheLocally(themeId, vars);++ if (opts && opts.sync === false) return;++ if (typeof fetch === 'undefined') return;++ const token = authToken();++ const headers = { 'Content-Type': 'application/json' };++ if (token) headers['Authorization'] = 'Bearer ' + token;++ fetch(_config.apiBase + '/api/themes/me', {++ method: 'PUT', headers: headers, credentials: 'include',++ body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),++ }).catch(function () { /* offline / signed out — local cache still holds */ });++ }++++ /**++ * Get the current theme ID (from cookie or localStorage).++ */++ function getCurrent() {++ try {++ const raw = localStorage.getItem(STORAGE_KEY);++ if (raw) {++ const saved = JSON.parse(raw);++ if (saved && (saved.id || saved.slug)) return saved.id || saved.slug;++ }++ } catch {}++ return getCookie(COOKIE_NAME) || 'vibe';++ }++++ // ── Auto-apply on load (synchronous, prevents FOUC) ─────++ // ── Display preferences: motion + text size, one setting for every OpenVibe site ─────────────++ // OpenVibeThemeLoader.display.get() → { motion: 'auto'|'reduced', text: '100'|'112'|'125' }++ // OpenVibeThemeLoader.display.set({ motion, text }) applies here, in other tabs, and saves to the account++ // Applied as <html data-ov-motion> and --ov-text-scale before first paint; the shared stylesheet++ // below makes every shared component honour them, and sites can key their own CSS off the same hooks.++ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'] };++ function readDisplay() {++ const out = { motion: 'auto', text: '100' };++ try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }++ return out;++ }++ function applyDisplay(d) {++ if (typeof document === 'undefined') return;++ const el = document.documentElement;++ if (d.motion === 'reduced') el.setAttribute('data-ov-motion', 'reduced'); else el.removeAttribute('data-ov-motion');++ if (d.text === '100') { el.style.removeProperty('--ov-text-scale'); el.removeAttribute('data-ov-text'); } else { el.style.setProperty('--ov-text-scale', String(parseInt(d.text, 10) / 100)); el.setAttribute('data-ov-text', d.text); }++ if (!document.getElementById('ov-display-css') && document.head) {++ const st = document.createElement('style'); st.id = 'ov-display-css';++ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-mark .o,html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}';++ document.head.appendChild(st);++ }++ try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }++ }++ function setDisplay(patch, opts) {++ const d = readDisplay();++ for (const k in DISPLAY_OPTS) if (patch && DISPLAY_OPTS[k].indexOf(String(patch[k])) >= 0) d[k] = String(patch[k]);++ try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(d)); } catch (e) { /* */ }++ applyDisplay(d);++ if (!(opts && opts.local) && typeof fetch !== 'undefined') {++ const token = authToken(); const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;++ if (token || /(?:^|; )ov_sso_hint=account/.test(document.cookie)) fetch(_config.apiBase + '/api/themes/me/display', { method: 'PUT', headers: headers, credentials: 'include', body: JSON.stringify(d) }).catch(function () {});++ }++ return d;++ }++ applyDisplay(readDisplay());++++ const activeId = resolveAndApply();++ // The icon link may be parsed after this script: repaint once the head is complete.++ if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { _browserUiKey = ''; syncBrowserUi(); });++ // A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.++ if (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) applyDisplay(readDisplay()); });++++ // Look up a theme we don't ship in the built-in map (community/custom themes) from the++ // central catalog. Without this, any non-built-in id fell through to the default 'vibe'++ // — which is why a custom theme could "disappear" and flash purple on every load.++ function fetchThemeVars(themeId) {++ return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) { return (d && d.theme && d.theme.variables) || null; })++ .catch(function () { return null; });++ }++++ // ── Pull the account's theme from openvibe.network ───────++ // Runs IMMEDIATELY, not on a 500ms timer: the cached theme is already painted, so this is++ // a silent correction — delaying it only widened the window where a cold or stale cache++ // showed the wrong theme. There is also no token gate any more; the ov_token cookie rides++ // along via credentials:'include' (openvibe.network allows credentialed CORS per origin),++ // so this now works on sites where the cookie isn't readable from JS — previously the sync++ // silently never ran there and the user's theme appeared to be "lost".++ function syncFromServer() {++ if (typeof fetch === 'undefined') return;++ const token = authToken();++ const headers = token ? { 'Authorization': 'Bearer ' + token } : {};++ fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (data) {++ if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text }) !== JSON.stringify(cur)) setDisplay(data.display, { local: true }); }++ if (!data || !data.theme_id) return;++ const serverId = data.theme_id;++ const custom = (data.custom_variables && typeof data.custom_variables === 'object'++ && Object.keys(data.custom_variables).length) ? data.custom_variables : null;++ const builtin = THEMES[serverId];++ const settle = function (base) {++ const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);++ if (!vars || !Object.keys(vars).length) return;++ applyVars(vars);++ cacheLocally(serverId, vars); // cacheLocally, not save() — no write-back loop++ };++ if (builtin) return settle(builtin);++ fetchThemeVars(serverId).then(settle);++ })++ .catch(function () { /* offline / signed out — keep the cached theme */ });++ }++ syncFromServer();++++ // ── Public API ───────────────────────────────────────────++ const OpenVibeThemeLoader = {++ THEMES: THEMES,++ display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS },++ apply: applyById,++ applyVars: applyVars,++ save: save,++ getCurrent: getCurrent,++ resolveAndApply: resolveAndApply,++ init: function (opts) {++ if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {++ _config.apiBase = opts.apiBase.trim();++ }++ return OpenVibeThemeLoader;++ },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeThemeLoader;++ else root.OpenVibeThemeLoader = OpenVibeThemeLoader;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -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,#60a5fa)) 45%, var(--border,rgba(255,255,255,.1)));++ box-shadow:0 20px 50px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.04),0 0 30px color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 22%, transparent);++ color:var(--text-secondary,#b6bdd2);font-size:.83rem;line-height:1.55;pointer-events:none;opacity:0;++ transform:translateY(6px) scale(.97);transition:opacity .18s ease,transform .22s cubic-bezier(.2,1.3,.3,1);++ overflow:hidden}++.ovtip.is-on{opacity:1;transform:none}++.ovtip.is-interactive{pointer-events:auto}++.ovtip::before{content:'';position:absolute;inset:0;pointer-events:none;++ background:radial-gradient(120% 90% at 0% 0%, color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 16%, transparent), transparent 60%)}++/* A light sweep on open — draws the eye without moving anything around it. */++.ovtip::after{content:'';position:absolute;top:0;bottom:0;left:-60%;width:45%;pointer-events:none;++ background:linear-gradient(100deg,transparent,color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 26%, transparent),transparent)}++.ovtip.is-on::after{animation:ovtipSweep .9s cubic-bezier(.3,.7,.3,1) .1s 1}++@keyframes ovtipSweep{to{transform:translateX(420%)}}++.ovtip-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;position:relative}++.ovtip-ico{width:26px;height:26px;border-radius:8px;display:grid;place-items:center;flex:none;font-size:.78rem;color:#fff;++ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#60a5fa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 55%,#000));++ box-shadow:0 4px 12px color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 45%, transparent)}++.ovtip-title{font-size:.9rem;font-weight:800;color:var(--text-primary,#f1f4fb);letter-spacing:-.01em}++.ovtip-body{position:relative}++.ovtip-body b,.ovtip-body strong{color:var(--text-primary,#f1f4fb);font-weight:700}++.ovtip-body em{color:var(--ovtip-accent,var(--accent,#60a5fa));font-style:normal;font-weight:600}++.ovtip-body code{background:var(--bg-tertiary,#1a1b28);padding:1px 6px;border-radius:5px;font-size:.78rem}++.ovtip-foot{margin-top:8px;padding-top:8px;border-top:1px dashed var(--border,rgba(255,255,255,.1));++ font-size:.74rem;color:var(--text-muted,#8b93ad);display:flex;align-items:center;gap:6px;position:relative}++.ovtip-arrow{position:fixed;z-index:30001;width:10px;height:10px;transform:rotate(45deg);opacity:0;transition:opacity .18s;++ background:var(--bg-card,#171826);border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 45%, var(--border,rgba(255,255,255,.1)))}++.ovtip-arrow.is-on{opacity:1}++.ovtip-arrow.from-top{border-top:0;border-left:0}++.ovtip-arrow.from-bottom{border-bottom:0;border-right:0}++.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,#60a5fa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 60%,#000));++ box-shadow:0 6px 16px color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 40%,transparent)}++.ovtip-dismiss{flex:0 0 auto;color:var(--text-muted,#8b93ad);background:none;border:1px solid var(--border,rgba(255,255,255,.12))}++.ovtip-go:active,.ovtip-dismiss:active{transform:scale(.975)}++.ovtip-go i{font-size:.72rem}++/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };++})();nnew file mode 100644@@@ -0,0 +1,175 @@++/**++ * 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: '…' })++ * OpenVibeUI.notice({ id: 'yt-upstream', type: 'warning', title: '…', message: '…', links: [{ label, href }], dismissible: true })++ * a page-level notice that always sits directly BELOW the site's navbar (never above it), one++ * design for service status, maintenance, read-only mode and similar. Returns { close, update }.++ *++ * 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() {} }; }, notice() { return { close() {}, update() {} }; }, 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}++.ovui-notices{display:flex;flex-direction:column;gap:8px;max-width:1080px;margin:14px auto 0;padding:0 16px;box-sizing:border-box;width:100%}++.ovui-notices:empty{display:none}++.ovui-notice{--ovui-c:var(--accent,#3b82f6);display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:start;padding:12px 14px;border-radius:14px;border:1px solid color-mix(in srgb,var(--ovui-c) 40%,transparent);background:color-mix(in srgb,var(--ovui-c) 9%,var(--bg-secondary,#111826));color:var(--text-primary,#e6edf7);font:400 14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1)}++.ovui-notice[data-type=success]{--ovui-c:var(--success,#22c55e)}.ovui-notice[data-type=error]{--ovui-c:var(--danger,#ef4444)}.ovui-notice[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}++.ovui-notice-ic{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--ovui-c);color:#0b1220;font:800 13px/1 system-ui,sans-serif;margin-top:1px}++.ovui-notice b{font-weight:700}.ovui-notice-msg{color:var(--text-secondary,#a8b3c4)}++.ovui-notice-links{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:6px}.ovui-notice-links a{color:var(--accent-light,var(--accent,#60a5fa));font-weight:600;text-decoration:none}.ovui-notice-links a:hover{text-decoration:underline}++@media (max-width:560px){.ovui-notices{margin-top:10px;padding:0 12px}}++@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();++ });++ }++++ // ── Page notices ─────────────────────────────────────────++ const notices = new Map();++ let noticeHost = null;++ function placeNoticeHost() {++ if (!noticeHost) { noticeHost = el('div', 'ovui-notices'); noticeHost.setAttribute('aria-live', 'polite'); }++ // Directly after the navbar, whichever flavour the site uses; otherwise at the top of the content.++ const nav = document.querySelector('nav.openvibe-navbar, #navbar-mount, .navbar, header[role="banner"]');++ const anchor = nav ? (nav.id === 'navbar-mount' || nav.parentNode === document.body ? nav : nav) : null;++ if (anchor && anchor.parentNode) { if (anchor.nextSibling !== noticeHost) anchor.parentNode.insertBefore(noticeHost, anchor.nextSibling); return true; }++ const main = document.querySelector('main') || document.body.firstElementChild;++ if (!noticeHost.isConnected && main && main.parentNode) main.parentNode.insertBefore(noticeHost, main);++ return false;++ }++ function notice(o) {++ o = o || {}; css();++ const id = String(o.id || 'n' + Date.now());++ try { if (o.dismissible !== false && sessionStorage.getItem('ovui_notice_' + id) === '1') return { close() {}, update() {} }; } catch { /* */ }++ if (notices.has(id)) notices.get(id).close(true);++ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';++ const n = el('div', 'ovui-notice'); n.dataset.type = type; n.setAttribute('role', type === 'error' ? 'alert' : 'status');++ n.appendChild(el('span', 'ovui-notice-ic', type === 'success' ? '✓' : type === 'info' ? 'i' : '!'));++ const body = el('div');++ const fill = (p) => {++ body.replaceChildren();++ const line = el('div'); if (p.title) { line.appendChild(el('b', null, p.title)); line.appendChild(document.createTextNode(' ')); }++ if (p.message) line.appendChild(el('span', 'ovui-notice-msg', p.message)); body.appendChild(line);++ const links = (p.links || []).map((l) => { const h = l && l.label && safeHref(l.href); if (!h) return null; const a = el('a', null, l.label); a.href = h; return a; }).filter(Boolean);++ if (links.length) { const row = el('div', 'ovui-notice-links'); links.forEach((a) => row.appendChild(a)); body.appendChild(row); }++ };++ fill(o); n.appendChild(body);++ const close = (silent) => { n.remove(); notices.delete(id); if (!silent && o.dismissible !== false) { try { sessionStorage.setItem('ovui_notice_' + id, '1'); } catch { /* */ } } };++ if (o.dismissible !== false) { const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close(false)); n.appendChild(x); }++ // The navbar may mount after us: keep trying briefly until the notice sits under it.++ let tries = 0; const settle = () => { const ok = placeNoticeHost(); if (!ok && ++tries < 40) setTimeout(settle, 150); }; settle();++ noticeHost.appendChild(n);++ const handle = { close: () => close(true), update: (p) => fill(Object.assign({}, o, p)), el: n };++ notices.set(id, handle);++ return handle;++ }++++ const api = {++ toast, notice,++ 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);nnew file mode 100644@@@ -0,0 +1,252 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — User Card & Context Menu++// Right-click any username to see profile card, quick actions,++// linked services, name effects, and moderator tools.++// Usage: OpenVibeUserCard.init({ apiBase, token, currentUserId })++// OpenVibeUserCard.attach(element, userId)++// ═══════════════════════════════════════════════════════════════++++(function (root) {++ 'use strict';++++ let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };++ let _cardEl = null;++ let _cache = new Map();++++ function injectStyles() {++ if (document.getElementById('openvibe-usercard-styles')) return;++ const s = document.createElement('style');++ s.id = 'openvibe-usercard-styles';++ s.textContent = `++ @keyframes openvibe-card-in { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }++ .openvibe-usercard-overlay { position: fixed; inset: 0; z-index: 99998; }++ .openvibe-usercard {++ position: fixed; z-index: 99999;++ width: 300px; background: var(--bg-card, #22222c);++ border: 1px solid var(--border, #333340); border-radius: 12px;++ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));++ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;++ color: var(--text-primary, #e0e0e0);++ animation: openvibe-card-in .2s ease; overflow: hidden;++ }++ .openvibe-usercard-banner {++ height: 60px; background: linear-gradient(135deg, var(--accent-dark, #a07840), var(--accent, #3b82f6));++ position: relative;++ }++ .openvibe-usercard-avatar {++ width: 56px; height: 56px; border-radius: 50%;++ border: 3px solid var(--bg-card, #22222c);++ position: absolute; bottom: -28px; left: 16px;++ background: var(--bg-tertiary, #2a2a38);++ object-fit: cover;++ }++ .openvibe-usercard-body { padding: 36px 16px 12px; }++ .openvibe-usercard-name {++ font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 6px;++ }++ .openvibe-usercard-name .role-badge {++ font-size: 9px; padding: 2px 6px; border-radius: 3px;++ font-weight: 700; text-transform: uppercase; letter-spacing: .5px;++ }++ .openvibe-usercard-name .role-admin { background: rgba(231,76,60,0.15); color: #e74c3c; }++ .openvibe-usercard-name .role-global_mod { background: rgba(46,204,113,0.15); color: #2ecc71; }++ .openvibe-usercard-name .role-streamer { background: rgba(155,89,182,0.15); color: #9b59b6; }++ .openvibe-usercard-username { font-size: 12px; color: var(--text-muted, #707080); margin-bottom: 8px; }++ .openvibe-usercard-bio { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; margin-bottom: 10px; max-height: 40px; overflow: hidden; }++ .openvibe-usercard-services { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }++ .openvibe-usercard-services .svc {++ font-size: 10px; padding: 3px 8px; border-radius: 4px;++ background: var(--bg-tertiary, #2a2a38); color: var(--text-secondary, #b0b0b8);++ display: flex; align-items: center; gap: 4px;++ }++ .openvibe-usercard-services .svc .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success, #2ecc71); }++ .openvibe-usercard-divider { height: 1px; background: var(--border, #333340); margin: 8px -16px; }++ .openvibe-usercard-actions { display: flex; flex-direction: column; gap: 2px; }++ .openvibe-usercard-actions button {++ display: flex; align-items: center; gap: 8px;++ width: 100%; padding: 8px 12px; background: none;++ border: none; border-radius: 6px; color: var(--text-primary, #e0e0e0);++ font-size: 12px; cursor: pointer; text-align: left;++ transition: background .12s;++ }++ .openvibe-usercard-actions button:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-usercard-actions button.danger { color: var(--live-red, #e74c3c); }++ .openvibe-usercard-actions button .icon { font-size: 14px; width: 20px; text-align: center; }++++ /* Name effects (CSS classes applied to usernames) */++ .openvibe-name-fx-rainbow { background: linear-gradient(90deg, #ff0000, #ff7700, #ffff00, #00ff00, #0000ff, #8b00ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; background-size: 200%; animation: openvibe-rainbow 3s linear infinite; }++ @keyframes openvibe-rainbow { to { background-position: 200% center; } }++ .openvibe-name-fx-glow { text-shadow: 0 0 8px currentColor, 0 0 16px currentColor; }++ .openvibe-name-fx-fire { text-shadow: 0 0 4px #ff6600, 0 -2px 8px #ff3300, 0 -4px 12px #cc0000; }++ .openvibe-name-fx-ice { text-shadow: 0 0 6px #00ccff, 0 0 12px #0088ff; color: #aaddff; }++ .openvibe-name-fx-gold { background: linear-gradient(180deg, #ffd700, #daa520, #ffd700); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: none; }++ .openvibe-name-fx-glitch { animation: openvibe-glitch .1s infinite; }++ @keyframes openvibe-glitch { 0%,100%{transform:none} 25%{transform:translate(-1px,1px)} 50%{transform:translate(1px,-1px)} 75%{transform:translate(-1px,-1px)} }++ .openvibe-name-fx-neon { color: #39ff14; text-shadow: 0 0 5px #39ff14, 0 0 10px #39ff14, 0 0 20px #39ff14; }++ .openvibe-name-fx-shadow { text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }++ `;++ document.head.appendChild(s);++ }++++ // ── Fetch User Data ──────────────────────────────────────++ async function fetchUser(userId) {++ if (_cache.has(userId)) return _cache.get(userId);++ try {++ const headers = {};++ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;++ const res = await fetch(`${_config.apiBase}/api/users/${userId}/card`, { headers, credentials: 'include' });++ if (!res.ok) return null;++ const data = await res.json();++ _cache.set(userId, data);++ setTimeout(() => _cache.delete(userId), 120_000); // 2min cache++ return data;++ } catch { return null; }++ }++++ // ── Render Card ──────────────────────────────────────────++ function renderCard(data, x, y) {++ closeCard();++ const user = data.user;++ const linked = data.linked_accounts || [];++++ const roleBadge = user.role !== 'user'++ ? `<span class="role-badge role-${user.role}">${user.role.replace('_', ' ')}</span>` : '';++++ const nameClass = data.name_effect ? `openvibe-name-fx-${data.name_effect}` : '';++ const nameColor = user.profile_color ? `color:${user.profile_color}` : '';++++ const services = linked.map(l => {++ const icons = { openvibelive: '📡', openvibegames: '⚔️', openvibemaps: '🗺️' };++ return `<span class="svc"><span class="dot"></span>${icons[l.service] || '🔗'} ${l.service_username || l.service}</span>`;++ }).join('');++++ const isSelf = _config.currentUserId && String(_config.currentUserId) === String(user.id);++ const isMod = ['admin', 'global_mod'].includes(_config.currentRole);++++ let actions = '';++ if (!isSelf) {++ actions += `<button data-act="profile"><span class="icon">👤</span> View Profile</button>`;++ actions += `<button data-act="message"><span class="icon">💬</span> Send Message</button>`;++ actions += `<button data-act="follow"><span class="icon">➕</span> Follow</button>`;++ if (isMod) {++ actions += `<div class="openvibe-usercard-divider"></div>`;++ actions += `<button data-act="warn"><span class="icon">⚠️</span> Warn User</button>`;++ actions += `<button data-act="mute"><span class="icon">🔇</span> Mute</button>`;++ actions += `<button data-act="ban" class="danger"><span class="icon">🚫</span> Ban User</button>`;++ }++ } else {++ actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;++ actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;++ }++++ const card = document.createElement('div');++ card.className = 'openvibe-usercard';++++ const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;++ card.innerHTML = `++ <div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>++ <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" onerror="this.style.display='none'">++ <div class="openvibe-usercard-body">++ <div class="openvibe-usercard-name">++ <span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>++ ${roleBadge}++ </div>++ <div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>++ ${user.bio ? `<div class="openvibe-usercard-bio">${user.bio}</div>` : ''}++ ${services ? `<div class="openvibe-usercard-services">${services}</div>` : ''}++ <div class="openvibe-usercard-divider"></div>++ <div class="openvibe-usercard-actions">${actions}</div>++ </div>++ `;++++ // Position++ const vw = window.innerWidth, vh = window.innerHeight;++ card.style.left = Math.min(x, vw - 320) + 'px';++ card.style.top = Math.min(y, vh - 400) + 'px';++++ // Overlay to close on click-outside++ const overlay = document.createElement('div');++ overlay.className = 'openvibe-usercard-overlay';++ overlay.addEventListener('click', closeCard);++ overlay.addEventListener('contextmenu', e => { e.preventDefault(); closeCard(); });++++ document.body.appendChild(overlay);++ document.body.appendChild(card);++ _cardEl = { card, overlay };++++ // Action handlers++ card.querySelectorAll('[data-act]').forEach(btn => {++ btn.addEventListener('click', () => {++ const act = btn.dataset.act;++ closeCard();++ if (act === 'profile') {++ const url = isSelf ? `${_config.apiBase}/my`++ : `${_config.apiBase}/user/${user.username}`;++ window.open(url, '_blank');++ } else if (act === 'message') {++ // Dispatch custom event for the host app to handle++ document.dispatchEvent(new CustomEvent('openvibe-user-action', {++ detail: { action: act, user }++ }));++ } else {++ document.dispatchEvent(new CustomEvent('openvibe-user-action', {++ detail: { action: act, user }++ }));++ }++ });++ });++ }++++ function closeCard() {++ if (_cardEl) {++ _cardEl.card.remove();++ _cardEl.overlay.remove();++ _cardEl = null;++ }++ }++++ // ── Public API ───────────────────────────────────────────++ const OpenVibeUserCard = {++ init(opts = {}) {++ Object.assign(_config, opts);++ injectStyles();++ },++++ /** Attach right-click card to an element. */++ attach(el, userId) {++ el.style.cursor = 'pointer';++ el.addEventListener('contextmenu', async e => {++ e.preventDefault();++ const data = await fetchUser(userId);++ if (data) renderCard(data, e.clientX, e.clientY);++ });++ // Also support click on mobile++ el.addEventListener('click', async e => {++ if (e.ctrlKey || e.metaKey) {++ e.preventDefault();++ const data = await fetchUser(userId);++ if (data) renderCard(data, e.clientX, e.clientY);++ }++ });++ },++++ /** Programmatically show a card at position. */++ async show(userId, x, y) {++ const data = await fetchUser(userId);++ if (data) renderCard(data, x, y);++ },++++ close: closeCard,++ clearCache() { _cache.clear(); },++++ /** Apply name effect CSS class to an element. */++ applyNameEffect(el, effectName) {++ if (!effectName) return;++ el.classList.add(`openvibe-name-fx-${effectName}`);++ },++ };++++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;++ else root.OpenVibeUserCard = OpenVibeUserCard;++++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);nnew file mode 100644@@@ -0,0 +1,447 @@++/*++ * openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P task 6), taken out of++ * OpenVibe.Live's public/js/ov-loader.js so every OpenVibe site loads its code the same way.++ *++ * const rt = OVWebRuntime.create({ features, routes, versions, ... }); // registry: see README "Web runtime"++ * rt.load('broadcast') → the feature's markup fragment, its dependencies, stylesheets and scripts (in order), then++ * its `after` hook; once, cached; a failed load can be retried.++ * rt.route(path) → every feature the path's routes name, as one promise.++ * rt.prefetch('channel') → download only (link rel=prefetch), never execute; skipped on Save-Data, 2g and < 2 GB++ * of memory, and within a budget (count, and bytes where sizes are known).++ * rt.nextRoute() / rt.gen() / rt.isCurrent(gen)++ * → route generations: a loader that awaited something can tell whether the visitor moved on.++ * rt.scope() → timers, listeners, observers, fetches (AbortSignal) and child scopes owned by the current++ * route, released by the next nextRoute(). Anything registered on a scope whose route has++ * ended is refused (and counted), never left running.++ * rt.diagnostics(), rt.leaks()++ * → loaded, failed and rolled-back features, duplicate tags, late registrations, what the++ * current route holds, and the prefetch budget spent.++ *++ * Asset groups are transactional: a feature counts as loaded, runs its hook and fires `<prefix>:feature` only when++ * every script loaded. When one fails, the stylesheets that attempt added (and no other feature wants) are taken out++ * again and the failed script's tag is removed, so a retry fetches exactly what is missing. Scripts that did run are++ * kept, so nothing runs twice.++ * Tags the server already put in the document count as loaded. Global stubs (installStubs) keep inline onclick++ * handlers working before their feature has loaded.++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OVWebRuntime) return;++++ /** The key an asset is known by: its path on this site, origin + path elsewhere; the query (?v=) never counts. */++ function assetKey(src) {++ try {++ const u = new URL(src, root.location.href);++ return u.origin === root.location.origin ? u.pathname : u.origin + u.pathname;++ } catch { return String(src).split(/[?#]/)[0]; }++ }++++ function create(o = {}) {++ const FEATURES = o.features || {};++ const ROUTES = (o.routes || []).map((r) => ({ re: r.path instanceof RegExp ? r.path : new RegExp(r.path), features: r.features || [] }));++ const versions = o.versions || {};++ const G = o.global || root;++ const prefix = o.eventPrefix || 'ov';++ const log = o.log || root.console;++ const styleTimeout = o.styleTimeout || 4000;++ const fragmentPath = o.fragmentPath || ((name) => `/fragments/${name}.html`);++ const url = o.url || ((p) => { const h = versions[p]; return h ? `${p}${p.indexOf('?') === -1 ? '?' : '&'}v=${h}` : p; });++ const d = { scripts: 0, styles: 0, late: 0, rolledBack: 0, failed: Object.create(null), scopes: 0, disposed: 0 };++ const emit = (type, detail, target) => (target || document).dispatchEvent(new root.CustomEvent(type, { bubbles: true, detail }));++ const failed = (kind, what, err) => { const f = d.failed[what] || (d.failed[what] = { kind, attempts: 0, error: '' }); f.attempts++; f.error = String((err && err.message) || err || ''); };++++ // ── Scripts and styles, once each ────────────────────────────────────────────────────────++ const scripts = Object.create(null); // key → promise++ const styles = Object.create(null); // key → promise++ const styleEls = Object.create(null); // key → the <link> this runtime inserted++ const wanted = Object.create(null); // key → Set of features loading or loaded that list it++++ function tagPromise(el, isScript) {++ // A deferred script that has run fired `load` long ago; after DOMContentLoaded every parser-inserted script has run.++ if (isScript && document.readyState !== 'loading' && !el.dataset.ovInjected) return Promise.resolve();++ return new Promise((resolve) => {++ el.addEventListener('load', () => resolve(), { once: true });++ el.addEventListener('error', () => resolve(), { once: true });++ if (isScript && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => resolve(), { once: true });++ });++ }++ /** Tags already in the document (the server's, or another loader's) count as loaded or loading. */++ function adoptExisting() {++ document.querySelectorAll('script[src]').forEach((t) => { const k = assetKey(t.getAttribute('src')); if (!scripts[k]) scripts[k] = tagPromise(t, true); });++ document.querySelectorAll('link[rel="stylesheet"][href]').forEach((l) => { const k = assetKey(l.getAttribute('href')); if (!styles[k]) styles[k] = l.sheet ? Promise.resolve() : tagPromise(l, false); });++ }++++ /** attrs: { integrity, crossOrigin } for another origin's file. */++ function loadScript(p, attrs) {++ const k = assetKey(p);++ if (scripts[k]) return scripts[k];++ const s = document.createElement('script');++ s.src = url(p);++ s.async = false; // injected together → run in insertion order++ s.dataset.ovInjected = '1';++ if (attrs && attrs.integrity) s.integrity = attrs.integrity;++ if (attrs && attrs.crossOrigin) s.crossOrigin = attrs.crossOrigin;++ scripts[k] = new Promise((resolve, reject) => {++ s.onload = () => { delete d.failed[k]; resolve(); };++ s.onerror = () => {++ delete scripts[k]; // a retry adds a fresh tag++ s.remove();++ const err = new Error(`Could not load ${p}`);++ failed('script', k, err);++ reject(err);++ };++ });++ document.head.appendChild(s);++ d.scripts++;++ return scripts[k];++ }++++ /** Never rejects: a missing stylesheet must not block a route (it waits at most styleTimeout ms). */++ function loadStyle(p, added) {++ const k = assetKey(p);++ if (styles[k]) return styles[k];++ const l = document.createElement('link');++ l.rel = 'stylesheet';++ l.href = url(p);++ const pr = styles[k] = new Promise((resolve) => {++ let done = false;++ const finish = () => { if (!done) { done = true; resolve(); } };++ l.onload = () => { delete d.failed[k]; finish(); };++ l.onerror = () => {++ if (styles[k] === pr) { delete styles[k]; delete styleEls[k]; }++ l.remove();++ failed('style', k, 'error');++ finish();++ };++ root.setTimeout(finish, styleTimeout);++ });++ // o.styleSlot(p) → the node it goes before (a site's cascade order), or nothing: last in <head>.++ const before = o.styleSlot ? o.styleSlot(p) : null;++ if (before && before.parentNode) before.parentNode.insertBefore(l, before);++ else document.head.appendChild(l);++ styleEls[k] = l;++ d.styles++;++ if (added) added.push(k);++ return pr;++ }++++ // ── Markup fragments ─────────────────────────────────────────────────────────────────────++ const fragments = Object.create(null);++ function loadFragment(name, sectionId) {++ const section = document.getElementById(sectionId);++ if (!section || section.dataset.fragmentLoaded === '1') return Promise.resolve();++ if (fragments[name]) return fragments[name];++ section.setAttribute('aria-busy', 'true');++ fragments[name] = root.fetch(url(fragmentPath(name)), { credentials: 'same-origin' })++ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); })++ .then((html) => {++ // Inserted once, never replaced: players, previews and call tiles bind to these exact nodes.++ if (section.dataset.fragmentLoaded !== '1') {++ section.innerHTML = html;++ section.dataset.fragmentLoaded = '1';++ emit(`${prefix}:fragment`, { name }, section);++ }++ section.removeAttribute('aria-busy');++ }, (err) => {++ delete fragments[name];++ section.removeAttribute('aria-busy');++ failed('fragment', name, err);++ throw err;++ });++ return fragments[name];++ }++++ // ── Features (transactional asset groups) ─────────────────────────────────────────────────++ const features = Object.create(null); // name → promise, in flight or loaded++ const loaded = Object.create(null);++ const afterRan = Object.create(null);++++ function load(name) {++ const f = FEATURES[name];++ if (!f) return Promise.resolve();++ if (features[name]) return features[name];++ const css = (f.css || []).map(assetKey);++ css.forEach((k) => (wanted[k] || (wanted[k] = new Set())).add(name));++ const added = [];++ // The feature's markup goes in before its dependencies and scripts run: modules bind to it when they load.++ const markup = f.fragment ? loadFragment(f.fragment, f.section || `page-${f.fragment}`) : Promise.resolve();++ features[name] = markup++ .then(() => Promise.all((f.deps || []).map(load)))++ .then(() => Promise.all((f.css || []).map((p) => loadStyle(p, added)).concat((f.js || []).map((p) => loadScript(p)))))++ .then(() => {++ loaded[name] = true;++ delete d.failed[name];++ if (f.after && !afterRan[name] && typeof G[f.after] === 'function') {++ afterRan[name] = true;++ try { G[f.after](); } catch (e) { log.error(`[${prefix}] ${name} after-hook failed:`, e); }++ }++ emit(`${prefix}:feature`, { name });++ }, (err) => {++ delete features[name];++ css.forEach((k) => wanted[k] && wanted[k].delete(name));++ for (const k of added) {++ if (wanted[k] && wanted[k].size) continue;++ if (styleEls[k]) styleEls[k].remove();++ delete styleEls[k]; delete styles[k];++ d.rolledBack++;++ }++ failed('feature', name, err);++ throw err;++ });++ return features[name];++ }++++ function featuresFor(path) {++ const out = [];++ ROUTES.forEach((r) => { if (r.re.test(path)) r.features.forEach((n) => { if (out.indexOf(n) === -1) out.push(n); }); });++ return out;++ }++ function route(path) { return Promise.all(featuresFor(path || root.location.pathname).map(load)); }++++ // ── Prefetch (download, never execute), within a budget ──────────────────────────────────++ const pf = o.prefetch || {};++ const budget = { count: pf.count == null ? 60 : pf.count, bytes: pf.bytes == null ? 3 * 1024 * 1024 : pf.bytes };++ const sizes = pf.sizes || {};++ const spent = { count: 0, bytes: 0, skipped: 0, constrained: 0 };++ const prefetched = Object.create(null);++ function constrained() {++ const c = root.navigator && root.navigator.connection;++ if (c && (c.saveData || /(^|-)2g$/.test(c.effectiveType || ''))) return true;++ return !!root.navigator && typeof root.navigator.deviceMemory === 'number' && root.navigator.deviceMemory < 2;++ }++ function prefetchAsset(p) {++ const k = assetKey(p);++ if (prefetched[k] || scripts[k] || styles[k]) return;++ const size = Number(sizes[p]) || 0;++ if (spent.count + 1 > budget.count || spent.bytes + size > budget.bytes) { spent.skipped++; return; }++ prefetched[k] = true; spent.count++; spent.bytes += size;++ const l = document.createElement('link');++ l.rel = 'prefetch';++ l.href = url(p);++ l.setAttribute('as', /\.css$/.test(k) ? 'style' : /\.js$/.test(k) ? 'script' : 'fetch');++ document.head.appendChild(l);++ }++ function prefetch(name, seen) {++ seen = seen || Object.create(null);++ const f = FEATURES[name];++ if (!f || seen[name] || features[name]) return;++ if (constrained()) { spent.constrained++; return; }++ seen[name] = true;++ (f.deps || []).forEach((n) => prefetch(n, seen));++ (f.css || []).concat(f.js || []).forEach(prefetchAsset);++ if (f.fragment) prefetchAsset(fragmentPath(f.fragment));++ }++ function prefetchRoute(path) { featuresFor(path).forEach((n) => prefetch(n)); }++++ /** Hover, focus or touch on an in-site link: fetch that route's code. Returns a function that stops watching. */++ function watchIntent(target) {++ const t = target || document;++ const on = (e) => {++ const a = e.target && e.target.closest && e.target.closest('a[href^="/"]');++ const href = a && a.getAttribute('href');++ if (!href || href.charAt(1) === '/') return;++ const path = href.split(/[?#]/)[0];++ if (path) prefetchRoute(path);++ };++ const kinds = [['pointerover', { passive: true }], ['focusin', undefined], ['touchstart', { passive: true }]];++ kinds.forEach(([k, opts]) => t.addEventListener(k, on, opts));++ return () => kinds.forEach(([k, opts]) => t.removeEventListener(k, on, opts));++ }++ /** The `idle` features of the path's routes, prefetched once the page has settled (load + 4 s + idle). */++ function idlePrefetch(path) {++ const idle = root.requestIdleCallback || ((fn) => root.setTimeout(fn, 3000));++ const go = () => root.setTimeout(() => idle(() => {++ featuresFor(path || root.location.pathname).forEach((n) => (FEATURES[n].idle || []).forEach((m) => prefetch(m)));++ }, { timeout: 15000 }), 4000);++ if (document.readyState === 'complete') go();++ else root.addEventListener('load', go, { once: true });++ }++++ // ── Route generations and scopes ─────────────────────────────────────────────────────────++ let generation = 0;++ let current = null;++++ function makeScope(gen) {++ const held = new Set();++ const controller = typeof root.AbortController === 'function' ? new root.AbortController() : null;++ let disposed = false;++ d.scopes++;++ const late = (kind) => { d.late++; if (o.debug) log.warn(`[${prefix}] ${kind} registered after its route ended (generation ${gen}); ignored`); };++ const hold = (kind, fn) => { const h = { kind, fn }; held.add(h); return h; };++ const scope = {++ gen,++ signal: controller ? controller.signal : undefined,++ interval(fn, ms) {++ if (disposed) { late('interval'); return 0; }++ const id = root.setInterval(fn, ms);++ hold('interval', () => root.clearInterval(id));++ return id;++ },++ timeout(fn, ms) {++ if (disposed) { late('timeout'); return 0; }++ let h = null;++ const id = root.setTimeout(function () { held.delete(h); return fn.apply(this, arguments); }, ms);++ h = hold('timeout', () => root.clearTimeout(id));++ return id;++ },++ listen(target, type, fn, opts) {++ if (!target) return;++ if (disposed) { late('listener'); return; }++ target.addEventListener(type, fn, opts);++ hold('listener', () => target.removeEventListener(type, fn, opts));++ },++ observe(observer) {++ if (disposed) { late('observer'); try { observer.disconnect(); } catch { /* */ } return observer; }++ hold('observer', () => { try { observer.disconnect(); } catch { /* */ } });++ return observer;++ },++ onDispose(fn) {++ if (disposed) { late('onDispose'); try { fn(); } catch { /* */ } return; }++ hold('onDispose', fn);++ },++ /** fetch() cancelled when the route ends (unless init brings its own signal). */++ fetch(input, init) {++ if (disposed) { late('fetch'); return Promise.reject(Object.assign(new Error('The route has ended'), { name: 'AbortError' })); }++ return root.fetch(input, Object.assign({ signal: scope.signal }, init));++ },++ /** A scope for one component (a dialog, a player) that can end early and ends with this one anyway. */++ child() {++ const c = makeScope(gen);++ if (disposed) { late('child'); c.dispose(); return c; }++ const h = hold('child', () => c.dispose());++ c.onDispose(() => held.delete(h));++ return c;++ },++ /** What this scope holds right now, by kind. */++ held() {++ const out = {};++ held.forEach((h) => { out[h.kind] = (out[h.kind] || 0) + 1; });++ return out;++ },++ get disposed() { return disposed; },++ dispose() {++ if (disposed) return;++ disposed = true;++ d.disposed++;++ if (controller) try { controller.abort(); } catch { /* */ }++ const list = Array.from(held).reverse();++ held.clear();++ for (const h of list) { try { h.fn(); } catch (e) { log.warn(`[${prefix}] scope cleanup failed:`, e); } }++ },++ };++ return scope;++ }++ function scope() { if (!current) current = makeScope(generation); return current; }++ /** A new route: everything the previous one owned is released. Returns the new generation. */++ function nextRoute() {++ generation++;++ if (current) current.dispose();++ current = makeScope(generation);++ return generation;++ }++++ // ── Stubs for inline handlers ────────────────────────────────────────────────────────────++ function installStubs() {++ Object.keys(FEATURES).forEach((name) => {++ (FEATURES[name].stubs || []).forEach((fn) => {++ if (typeof G[fn] === 'function') return;++ const stub = function () {++ const args = arguments; const self = this;++ return load(name).then(() => {++ if (G[fn] !== stub && typeof G[fn] === 'function') return G[fn].apply(self, args);++ log.warn(`[${prefix}] ${fn} is unavailable after loading ${name}`);++ }, (err) => {++ log.error(`[${prefix}]`, err);++ if (typeof o.onStubError === 'function') o.onStubError(err, name);++ });++ };++ stub.__ovStub = name;++ G[fn] = stub;++ });++ });++ }++++ // ── Recoverable failure UI for a route ───────────────────────────────────────────────────++ const RE = Object.assign({ icon: '', button: 'btn btn-primary', title: 'This page could not load.', text: 'Check your connection, then try again.', retry: 'Try again' }, o.routeError);++ function showRouteError(pageId, err, retry) {++ const page = document.getElementById(pageId);++ if (!page) return;++ let box = page.querySelector(':scope > .ov-route-error');++ if (!box) {++ box = document.createElement('div');++ box.className = 'ov-route-error';++ box.setAttribute('role', 'alert');++ page.insertBefore(box, page.firstChild);++ }++ box.textContent = '';++ if (RE.icon) { const i = document.createElement('i'); i.className = RE.icon; i.setAttribute('aria-hidden', 'true'); box.appendChild(i); }++ const msg = document.createElement('div');++ const strong = document.createElement('strong'); strong.textContent = RE.title;++ const span = document.createElement('span'); span.textContent = RE.text;++ msg.appendChild(strong); msg.appendChild(span); box.appendChild(msg);++ const btn = document.createElement('button');++ btn.type = 'button'; btn.className = RE.button; btn.textContent = RE.retry;++ btn.addEventListener('click', () => { box.remove(); retry(); }, { once: true });++ box.appendChild(btn);++ log.error(`[${prefix}] route failed to load:`, err);++ }++++ // ── Diagnostics ──────────────────────────────────────────────────────────────────────────++ function duplicates() {++ const n = Object.create(null);++ document.querySelectorAll('script[src], link[rel="stylesheet"][href]').forEach((el) => {++ const k = assetKey(el.getAttribute('src') || el.getAttribute('href'));++ n[k] = (n[k] || 0) + 1;++ });++ return Object.keys(n).filter((k) => n[k] > 1).map((k) => ({ asset: k, count: n[k] }));++ }++ function diagnostics() {++ return {++ generation,++ features: {++ loaded: Object.keys(loaded),++ loading: Object.keys(features).filter((n) => !loaded[n]),++ failed: Object.keys(d.failed).filter((k) => d.failed[k].kind === 'feature').map((k) => ({ name: k, attempts: d.failed[k].attempts, error: d.failed[k].error })),++ },++ assets: {++ scripts: Object.keys(scripts).length, styles: Object.keys(styles).length,++ injected: { scripts: d.scripts, styles: d.styles },++ failed: Object.keys(d.failed).filter((k) => d.failed[k].kind !== 'feature').map((k) => ({ asset: k, kind: d.failed[k].kind, attempts: d.failed[k].attempts })),++ rolledBack: d.rolledBack,++ duplicates: duplicates(),++ },++ scopes: { created: d.scopes, disposed: d.disposed, late: d.late, current: current ? current.held() : {} },++ prefetch: Object.assign({ budget: Object.assign({}, budget) }, spent),++ };++ }++ /** Plain sentences about anything that looks like a leak; empty when all is well. */++ function leaks() {++ const out = [];++ duplicates().forEach((x) => out.push(`${x.asset} is in the document ${x.count} times`));++ if (d.late) out.push(`${d.late} registration(s) arrived after their route ended (refused)`);++ return out;++ }++++ return {++ load, route, featuresFor, prefetch, prefetchRoute, loadScript, loadStyle, loadFragment, url, assetKey,++ gen: () => generation,++ isCurrent: (g) => g === generation,++ scope, nextRoute, installStubs, showRouteError, adoptExisting, watchIntent, idlePrefetch, diagnostics, leaks,++ isLoaded: (name) => !!features[name],++ /** Adopt the server's tags, load `initial` features, watch link intent, prefetch the idle features. */++ boot(b = {}) {++ const start = () => {++ adoptExisting();++ (b.initial || []).forEach((n) => { load(n).catch(() => { /* recorded in diagnostics */ }); });++ if (b.intent !== false) watchIntent(document);++ if (b.idle !== false) idlePrefetch();++ };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });++ else start();++ },++ };++ }++++ root.OVWebRuntime = { create, assetKey };++ if (typeof module !== 'undefined' && module.exports) module.exports = root.OVWebRuntime;++})(typeof window !== 'undefined' ? window : globalThis);@@@ -24,9 +24,9 @@<meta name="twitter:title" content="admin.openvibe.network — One operator console for every service."><meta name="twitter:description" content="admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://admin.openvibe.network/#site","url":"https://admin.openvibe.network/","name":"admin.openvibe.network","description":"admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://admin.openvibe.network/#page","url":"https://admin.openvibe.network/","name":"admin.openvibe.network — One operator console for every service.","description":"admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.","isPartOf":{"@id":"https://admin.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"admin.openvibe.network","item":"https://admin.openvibe.network/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://admin.openvibe.network/#site","url":"https://admin.openvibe.network/","name":"admin.openvibe.network","description":"admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://admin.openvibe.network/#page","url":"https://admin.openvibe.network/","name":"admin.openvibe.network — One operator console for every service.","description":"admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.","isPartOf":{"@id":"https://admin.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"admin.openvibe.network","item":"https://admin.openvibe.network/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'admin', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'admin.openvibe.network' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "admin",-- "release": "14f0403f18ea",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "7f7ac1ecbc83",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://admin.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://admin.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": null,"factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="ai.openvibe.network — AI as a service, for every OpenVibe product."><meta name="twitter:description" content="OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://ai.openvibe.network/#site","url":"https://ai.openvibe.network/","name":"ai.openvibe.network","description":"OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://ai.openvibe.network/#page","url":"https://ai.openvibe.network/","name":"ai.openvibe.network — AI as a service, for every OpenVibe product.","description":"OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.","isPartOf":{"@id":"https://ai.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"ai.openvibe.network","item":"https://ai.openvibe.network/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://ai.openvibe.network/#site","url":"https://ai.openvibe.network/","name":"ai.openvibe.network","description":"OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://ai.openvibe.network/#page","url":"https://ai.openvibe.network/","name":"ai.openvibe.network — AI as a service, for every OpenVibe product.","description":"OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.","isPartOf":{"@id":"https://ai.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"ai.openvibe.network","item":"https://ai.openvibe.network/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'ai', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'ai.openvibe.network' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "ai",-- "release": "9afb11c99afa",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "bf0b27e48124",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://ai.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://ai.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "a public API with its own access rules; today only other OpenVibe services call it","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="api.openvibe.network — The network, machine-readable."><meta name="twitter:description" content="api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://api.openvibe.network/#site","url":"https://api.openvibe.network/","name":"api.openvibe.network","description":"api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://api.openvibe.network/#page","url":"https://api.openvibe.network/","name":"api.openvibe.network — The network, machine-readable.","description":"api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.","isPartOf":{"@id":"https://api.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"api.openvibe.network","item":"https://api.openvibe.network/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://api.openvibe.network/#site","url":"https://api.openvibe.network/","name":"api.openvibe.network","description":"api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://api.openvibe.network/#page","url":"https://api.openvibe.network/","name":"api.openvibe.network — The network, machine-readable.","description":"api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.","isPartOf":{"@id":"https://api.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"api.openvibe.network","item":"https://api.openvibe.network/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -252,9 +252,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'api', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'api.openvibe.network' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "api",-- "release": "dba0ab1ed6a1",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "1b08f39023f9",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://api.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://api.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": null,"factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="auth.openvibe.network — Sign in, once, for the whole network."><meta name="twitter:description" content="auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://auth.openvibe.network/#site","url":"https://auth.openvibe.network/","name":"auth.openvibe.network","description":"auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://auth.openvibe.network/#page","url":"https://auth.openvibe.network/","name":"auth.openvibe.network — Sign in, once, for the whole network.","description":"auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.","isPartOf":{"@id":"https://auth.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"auth.openvibe.network","item":"https://auth.openvibe.network/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://auth.openvibe.network/#site","url":"https://auth.openvibe.network/","name":"auth.openvibe.network","description":"auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://auth.openvibe.network/#page","url":"https://auth.openvibe.network/","name":"auth.openvibe.network — Sign in, once, for the whole network.","description":"auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.","isPartOf":{"@id":"https://auth.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"auth.openvibe.network","item":"https://auth.openvibe.network/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -251,9 +251,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'auth', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'auth.openvibe.network' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "auth",-- "release": "59fa3ef72cd0",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "fe03dc632477",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://auth.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://auth.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": null,"factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="OpenRe.Stream — Ingest and restream, in the open."><meta name="twitter:description" content="OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openre.stream/#site","url":"https://openre.stream/","name":"OpenRe.Stream","description":"OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openre.stream/#page","url":"https://openre.stream/","name":"OpenRe.Stream — Ingest and restream, in the open.","description":"OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.","isPartOf":{"@id":"https://openre.stream/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenRe.Stream","item":"https://openre.stream/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openre.stream/#site","url":"https://openre.stream/","name":"OpenRe.Stream","description":"OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openre.stream/#page","url":"https://openre.stream/","name":"OpenRe.Stream — Ingest and restream, in the open.","description":"OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.","isPartOf":{"@id":"https://openre.stream/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenRe.Stream","item":"https://openre.stream/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -251,9 +251,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'stream', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenRe.Stream' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "stream",-- "release": "2a3844407d96",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "efb64c0c5635",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://openre.stream/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://openre.stream/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "the stream slots to move from OpenVibe.Live's ingest to OpenRe.Stream, one at a time; no stream uses it yet","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="OpenVibe.Coupons — Codes that actually work."><meta name="twitter:description" content="OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.coupons/#site","url":"https://openvibe.coupons/","name":"OpenVibe.Coupons","description":"OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.coupons/#page","url":"https://openvibe.coupons/","name":"OpenVibe.Coupons — Codes that actually work.","description":"OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.","isPartOf":{"@id":"https://openvibe.coupons/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Coupons","item":"https://openvibe.coupons/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.coupons/#site","url":"https://openvibe.coupons/","name":"OpenVibe.Coupons","description":"OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.coupons/#page","url":"https://openvibe.coupons/","name":"OpenVibe.Coupons — Codes that actually work.","description":"OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.","isPartOf":{"@id":"https://openvibe.coupons/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Coupons","item":"https://openvibe.coupons/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'coupons', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Coupons' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "coupons",-- "release": "f663069bb476",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "1d53415b3ea5",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://openvibe.coupons/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://openvibe.coupons/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "a person to verify a coupons source's terms and switch it on","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="OpenVibe.Deals — Deals worth seeing."><meta name="twitter:description" content="OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.deals/#site","url":"https://openvibe.deals/","name":"OpenVibe.Deals","description":"OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.deals/#page","url":"https://openvibe.deals/","name":"OpenVibe.Deals — Deals worth seeing.","description":"OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.","isPartOf":{"@id":"https://openvibe.deals/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Deals","item":"https://openvibe.deals/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.deals/#site","url":"https://openvibe.deals/","name":"OpenVibe.Deals","description":"OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.deals/#page","url":"https://openvibe.deals/","name":"OpenVibe.Deals — Deals worth seeing.","description":"OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.","isPartOf":{"@id":"https://openvibe.deals/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Deals","item":"https://openvibe.deals/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'deals', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Deals' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "deals",-- "release": "f5764f4254a7",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "94029ef33dc4",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://openvibe.deals/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://openvibe.deals/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "a person to verify a deals source's terms and switch it on","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="OpenVibe.Host — Static sites for OpenVibe projects."><meta name="twitter:description" content="OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.host/#site","url":"https://openvibe.host/","name":"OpenVibe.Host","description":"OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.host/#page","url":"https://openvibe.host/","name":"OpenVibe.Host — Static sites for OpenVibe projects.","description":"OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.","isPartOf":{"@id":"https://openvibe.host/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Host","item":"https://openvibe.host/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.host/#site","url":"https://openvibe.host/","name":"OpenVibe.Host","description":"OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.host/#page","url":"https://openvibe.host/","name":"OpenVibe.Host — Static sites for OpenVibe projects.","description":"OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.","isPartOf":{"@id":"https://openvibe.host/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Host","item":"https://openvibe.host/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -251,9 +251,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'host', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Host' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "host",-- "release": "df0d28a88e98",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "40bc066c9010",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://openvibe.host/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://openvibe.host/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "the owner's go on its launch checklist","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="OpenVibe.News — Sourced. Summarised. Many angles."><meta name="twitter:description" content="OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.news/#site","url":"https://openvibe.news/","name":"OpenVibe.News","description":"OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.news/#page","url":"https://openvibe.news/","name":"OpenVibe.News — Sourced. Summarised. Many angles.","description":"OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.","isPartOf":{"@id":"https://openvibe.news/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.News","item":"https://openvibe.news/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.news/#site","url":"https://openvibe.news/","name":"OpenVibe.News","description":"OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.news/#page","url":"https://openvibe.news/","name":"OpenVibe.News — Sourced. Summarised. Many angles.","description":"OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.","isPartOf":{"@id":"https://openvibe.news/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.News","item":"https://openvibe.news/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'news', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.News' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "news",-- "release": "56eb30198d36",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "f799922d60d7",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://openvibe.news/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://openvibe.news/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "a person to verify a news source's terms and an editor to publish the first reviewed story","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="OpenVibe.Reviews — What people actually say."><meta name="twitter:description" content="OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.reviews/#site","url":"https://openvibe.reviews/","name":"OpenVibe.Reviews","description":"OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.reviews/#page","url":"https://openvibe.reviews/","name":"OpenVibe.Reviews — What people actually say.","description":"OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.","isPartOf":{"@id":"https://openvibe.reviews/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Reviews","item":"https://openvibe.reviews/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.reviews/#site","url":"https://openvibe.reviews/","name":"OpenVibe.Reviews","description":"OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.reviews/#page","url":"https://openvibe.reviews/","name":"OpenVibe.Reviews — What people actually say.","description":"OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.","isPartOf":{"@id":"https://openvibe.reviews/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Reviews","item":"https://openvibe.reviews/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'reviews', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Reviews' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "reviews",-- "release": "d29c18b710ef",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "3ed60955d255",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://openvibe.reviews/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://openvibe.reviews/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "a person to verify a review source's terms and an editor to publish the first reviewed summary","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="OpenVibe.Tips — Support the people you watch."><meta name="twitter:description" content="OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.tips/#site","url":"https://openvibe.tips/","name":"OpenVibe.Tips","description":"OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.tips/#page","url":"https://openvibe.tips/","name":"OpenVibe.Tips — Support the people you watch.","description":"OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.","isPartOf":{"@id":"https://openvibe.tips/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Tips","item":"https://openvibe.tips/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.tips/#site","url":"https://openvibe.tips/","name":"OpenVibe.Tips","description":"OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.tips/#page","url":"https://openvibe.tips/","name":"OpenVibe.Tips — Support the people you watch.","description":"OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.","isPartOf":{"@id":"https://openvibe.tips/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Tips","item":"https://openvibe.tips/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'tips', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Tips' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "tips",-- "release": "529fe3b48ed7",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "47ba1331fdca",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://openvibe.tips/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://openvibe.tips/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "the payments cutover to OpenVibe.Billing and a security review; tips keep working on OpenVibe.Live meanwhile","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="OpenVibe.Trade — Watch, discuss, decide."><meta name="twitter:description" content="OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.trade/#site","url":"https://openvibe.trade/","name":"OpenVibe.Trade","description":"OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.trade/#page","url":"https://openvibe.trade/","name":"OpenVibe.Trade — Watch, discuss, decide.","description":"OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.","isPartOf":{"@id":"https://openvibe.trade/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Trade","item":"https://openvibe.trade/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.trade/#site","url":"https://openvibe.trade/","name":"OpenVibe.Trade","description":"OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.trade/#page","url":"https://openvibe.trade/","name":"OpenVibe.Trade — Watch, discuss, decide.","description":"OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.","isPartOf":{"@id":"https://openvibe.trade/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Trade","item":"https://openvibe.trade/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'trade', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Trade' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "trade",-- "release": "66b92483e1ef",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "141aea931221",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://openvibe.trade/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://openvibe.trade/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "a verified data source and editors adding the first instruments","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="OpenVibe.VIP — Memberships, network-wide."><meta name="twitter:description" content="OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.vip/#site","url":"https://openvibe.vip/","name":"OpenVibe.VIP","description":"OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.vip/#page","url":"https://openvibe.vip/","name":"OpenVibe.VIP — Memberships, network-wide.","description":"OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.","isPartOf":{"@id":"https://openvibe.vip/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.VIP","item":"https://openvibe.vip/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.vip/#site","url":"https://openvibe.vip/","name":"OpenVibe.VIP","description":"OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.vip/#page","url":"https://openvibe.vip/","name":"OpenVibe.VIP — Memberships, network-wide.","description":"OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.","isPartOf":{"@id":"https://openvibe.vip/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.VIP","item":"https://openvibe.vip/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'vip', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.VIP' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "vip",-- "release": "24ded64c1dc2",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "53c52ca7ae37",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://openvibe.vip/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://openvibe.vip/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": "the payments cutover to OpenVibe.Billing; checkout stays closed until then","factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="realtime.openvibe.network — Closed: realtime delivery is part of OpenVibe.Events."><meta name="twitter:description" content="OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://realtime.openvibe.network/#site","url":"https://realtime.openvibe.network/","name":"realtime.openvibe.network","description":"OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://realtime.openvibe.network/#page","url":"https://realtime.openvibe.network/","name":"realtime.openvibe.network — Closed: realtime delivery is part of OpenVibe.Events.","description":"OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.","isPartOf":{"@id":"https://realtime.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"realtime.openvibe.network","item":"https://realtime.openvibe.network/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://realtime.openvibe.network/#site","url":"https://realtime.openvibe.network/","name":"realtime.openvibe.network","description":"OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://realtime.openvibe.network/#page","url":"https://realtime.openvibe.network/","name":"realtime.openvibe.network — Closed: realtime delivery is part of OpenVibe.Events.","description":"OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.","isPartOf":{"@id":"https://realtime.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"realtime.openvibe.network","item":"https://realtime.openvibe.network/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -242,9 +242,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'realtime', apiBase: 'https://openvibe.network', links: [{ label: 'Where to go', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'realtime.openvibe.network' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "realtime",-- "release": "b7989d309d34",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "14c08a7c978e",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -18,6 +18,6 @@"launchWaitsFor": null,"factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="status.openvibe.network — Is OpenVibe up?"><meta name="twitter:description" content="OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://status.openvibe.network/#site","url":"https://status.openvibe.network/","name":"status.openvibe.network","description":"OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://status.openvibe.network/#page","url":"https://status.openvibe.network/","name":"status.openvibe.network — Is OpenVibe up?","description":"OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.","isPartOf":{"@id":"https://status.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"status.openvibe.network","item":"https://status.openvibe.network/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://status.openvibe.network/#site","url":"https://status.openvibe.network/","name":"status.openvibe.network","description":"OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://status.openvibe.network/#page","url":"https://status.openvibe.network/","name":"status.openvibe.network — Is OpenVibe up?","description":"OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.","isPartOf":{"@id":"https://status.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"status.openvibe.network","item":"https://status.openvibe.network/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -282,9 +282,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'status', apiBase: 'https://openvibe.network', links: [{ label: 'Where to go', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'status.openvibe.network' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "status",-- "release": "86ce11cc5012",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "fe837da2e2e9",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -18,6 +18,6 @@"launchWaitsFor": null,"factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -24,9 +24,9 @@<meta name="twitter:title" content="themes.openvibe.network — Your look, on every site."><meta name="twitter:description" content="themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders."><meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://themes.openvibe.network/#site","url":"https://themes.openvibe.network/","name":"themes.openvibe.network","description":"themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://themes.openvibe.network/#page","url":"https://themes.openvibe.network/","name":"themes.openvibe.network — Your look, on every site.","description":"themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.","isPartOf":{"@id":"https://themes.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"themes.openvibe.network","item":"https://themes.openvibe.network/"}]}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://themes.openvibe.network/#site","url":"https://themes.openvibe.network/","name":"themes.openvibe.network","description":"themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://themes.openvibe.network/#page","url":"https://themes.openvibe.network/","name":"themes.openvibe.network — Your look, on every site.","description":"themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.","isPartOf":{"@id":"https://themes.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"themes.openvibe.network","item":"https://themes.openvibe.network/"}]}}]}</script><script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>--<script src="https://openvibe.network/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>@@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo</span></div></div></footer>--<script src="https://openvibe.network/shared/ov-mark.js" async></script>--<script src="https://openvibe.network/shared/navbar.js"></script>--<script src="https://openvibe.network/shared/footer.js"></script>++<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>++<script src="/shared/navbar.js?v=b50d9f47fc34"></script>++<script src="/shared/footer.js?v=b99e512398d5"></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'themes', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'themes.openvibe.network' } }); } catch (e) {} }@@@ -1,11 +1,11 @@{"service": "themes",-- "release": "d63add6d31ee",-- "released_at": "2026-09-26T00:00:00.000Z",++ "release": "efa585a657c9",++ "released_at": "2026-09-27T00:00:00.000Z","booted_at": null,"contracts_version": null,"packages": {-- "openvibe-shared": "1.22.0"++ "openvibe-shared": "1.23.0"},"min_client_release": null,"mixed_version_window_hours": 24,@@@ -1,4 +1,4 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url><loc>https://themes.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>++ <url><loc>https://themes.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url></urlset>@@@ -18,6 +18,6 @@"launchWaitsFor": null,"factsAsOf": "2026-09-24","network": "https://openvibe.network",-- "updated": "2026-09-26",++ "updated": "2026-09-27","note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."}@@@ -6,7 +6,7 @@"": {"name": "openvibe-sites","dependencies": {-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"},"engines": {"node": ">=20"@@@ -119,9 +119,9 @@"license": "MIT"},"node_modules/openvibe-shared": {-- "version": "1.22.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",-- "integrity": "sha512-FtqbR8h6YphOnJNqklJED3HyqvTwZJl58gyiZ3QgzmZtZcA16O2NMGq67O4p7grLojwaIG5DhXDMgmZcpScxJQ==",++ "version": "1.23.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",++ "integrity": "sha512-9B3vXn5P7JjHWrp3cNh7ZynLOmCUgw9cuz6moksU3fyuIfy7uoepa2wJmQeSyLoPU8q/zKRJVeBVzPfdhr0z3g==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -7,7 +7,7 @@"test": "node test/build.test.js && node test/deploy-wrapper.test.js"},"dependencies": {-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"},"engines": {"node": ">=20"@@@ -43,6 +43,14 @@ for (const site of catalog.sites) {assert.strictEqual(rel.service, site.tld, `${d}: release.json service`);assert.match(rel.release, /^[0-9a-f]{12}$/, `${d}: release.json release`);assert.match(read(`deploy/nginx/${d}.conf`), /location = \/release\.json \{/, `${d}: nginx serves /release.json`);++ // D42: the frame from this repository's pin (dist/_shared/, aliased at /shared/), never openvibe.network's.++ assert.doesNotMatch(html, /https:\/\/openvibe\.network\/shared\/[\w.-]+\.js/, `${d}: loads a shared file from openvibe.network`);++ for (const m of html.matchAll(/src="\/shared\/([\w.-]+\.js)\?v=([0-9a-f]{12})"/g)) {++ assert.strictEqual(m[2], require('openvibe-shared/serve').hashOf(m[1]), `${d}: /shared/${m[1]} carries its content hash`);++ assert.strictEqual(read(`dist/_shared/${m[1]}`), fs.readFileSync(require('openvibe-shared/files').path(m[1]), 'utf8'), `${d}: dist/_shared/${m[1]} is the pinned file`);++ }++ assert.match(html, /src="\/shared\/navbar\.js\?v=/, `${d}: the navbar from /shared`);++ assert.match(read(`deploy/nginx/${d}.conf`), /location \^~ \/shared\/ \{\s+alias \/opt\/openvibe\.sites\/dist\/_shared\/;/, `${d}: nginx serves /shared/ from dist/_shared/`);assert.ok(html.includes(`data-ov-shipped="latest" data-service="${site.tld}"`), `${d}: the footer's shipped line`);if (site.kind) {// A notice (a closed product, a pointer) is not indexed, has no sitemap and is not listed as opening.
@@ -17,6 +17,10 @@ const fs = require('fs');
@@ -17,6 +17,10 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
// The network's legal documents, from the pinned OpenVibe.Shared release (package.json).
// The network's legal documents, from the pinned OpenVibe.Shared release (package.json).
const legal = require('openvibe-shared/legal');
const legal = require('openvibe-shared/legal');
+// D42 (roadmap WS-P task 4): the pages run this repository's pinned copy of the shared browser files, served by
+// every Sites vhost at /shared/ from one copy in dist/_shared/, content-addressed (?v=), never openvibe.network's.
+const sharedFiles = require('openvibe-shared/files');
+const SHARED = (name) => require('openvibe-shared/serve').url(name);
// Which clauses apply to each domain once it opens (mirrors OpenVibe.Network/server/frame/sites.js).
// Which clauses apply to each domain once it opens (mirrors OpenVibe.Network/server/frame/sites.js).
const LEGAL_PROFILE = { chat: 'ugc', codes: 'ugc', blog: 'info', wiki: 'ugc', news: 'info', reviews: 'ugc', tips: 'streaming', vip: 'account', trade: 'ugc', host: 'hosting', deals: 'info', coupons: 'info', stream: 'streaming' };
const LEGAL_PROFILE = { chat: 'ugc', codes: 'ugc', blog: 'info', wiki: 'ugc', news: 'info', reviews: 'ugc', tips: 'streaming', vip: 'account', trade: 'ugc', host: 'hosting', deals: 'info', coupons: 'info', stream: 'streaming' };
// Sites whose own server has no page routes: their legal pages are built here and served by nginx.
// Sites whose own server has no page routes: their legal pages are built here and served by nginx.
@@ -142,7 +146,7 @@ ${require('openvibe-shared/app-icon').CRITICAL}
@@ -142,7 +146,7 @@ ${require('openvibe-shared/app-icon').CRITICAL}
<meta name="twitter:image" content="${ogImage}">
<meta name="twitter:image" content="${ogImage}">
<script type="application/ld+json">${JSON.stringify(ld)}</script>
<script type="application/ld+json">${JSON.stringify(ld)}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="${NET.networkUrl}/shared/theme-loader.js" defer></script>
+<script src="${SHARED('theme-loader.js')}" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -263,9 +267,9 @@ ${site.statusApi ? liveStatus(site) : ''}` : ` <section id="what">
@@ -263,9 +267,9 @@ ${site.statusApi ? liveStatus(site) : ''}` : ` <section id="what">
</div>
</div>
</main>
</main>
${require('openvibe-shared/frame').footer({ service: site.tld, variant: 'full' })}
${require('openvibe-shared/frame').footer({ service: site.tld, variant: 'full' })}
-<script src="${NET.networkUrl}/shared/ov-mark.js" async></script>
-<script src="${NET.networkUrl}/shared/navbar.js"></script>
-<script src="${NET.networkUrl}/shared/footer.js"></script>
+<script src="${SHARED('ov-mark.js')}" async></script>
+<script src="${SHARED('navbar.js')}"></script>
+<script src="${SHARED('footer.js')}"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: '${esc(site.tld)}', apiBase: '${NET.networkUrl}', links: [{ label: '${notice ? 'Where to go' : 'What it will be'}', href: '#what' }, { label: 'The network', href: '${NET.networkUrl}/#network', external: false }], history: { type: 'page', title: '${esc(site.name)}' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: '${esc(site.tld)}', apiBase: '${NET.networkUrl}', links: [{ label: '${notice ? 'Where to go' : 'What it will be'}', href: '#what' }, { label: 'The network', href: '${NET.networkUrl}/#network', external: false }], history: { type: 'page', title: '${esc(site.name)}' } }); } catch (e) {} }
@@ -401,6 +405,15 @@ server {
@@ -401,6 +405,15 @@ server {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Origin "*";
${sec}
${sec}
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ ${sec}
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -517,6 +530,8 @@ function build() {
@@ -517,6 +530,8 @@ function build() {
out[`../deploy/nginx/${site.domain}.conf`] = vhost(site);
out[`../deploy/nginx/${site.domain}.conf`] = vhost(site);
}
}
for (const g of LEGAL_ONLY) for (const kind of ['terms', 'privacy', 'dmca']) out[`${g.domain}/${kind}.html`] = legal.page(kind, { id: g.id, service: g.id, host: g.domain, name: g.name, profile: g.profile });
for (const g of LEGAL_ONLY) for (const kind of ['terms', 'privacy', 'dmca']) out[`${g.domain}/${kind}.html`] = legal.page(kind, { id: g.id, service: g.id, host: g.domain, name: g.name, profile: g.profile });
+ // One copy of every browser file of the pinned openvibe-shared (the pages' scripts and what they load beside them).
+ for (const name of sharedFiles.BROWSER) out[`_shared/${name}`] = fs.readFileSync(sharedFiles.path(name), 'utf8');
return out;
return out;
}
}
@@ -71,6 +71,18 @@ server {
@@ -71,6 +71,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -71,6 +71,18 @@ server {
@@ -71,6 +71,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -71,6 +71,18 @@ server {
@@ -71,6 +71,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -71,6 +71,18 @@ server {
@@ -71,6 +71,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -81,6 +81,18 @@ server {
@@ -81,6 +81,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -81,6 +81,18 @@ server {
@@ -81,6 +81,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -81,6 +81,18 @@ server {
@@ -81,6 +81,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -81,6 +81,18 @@ server {
@@ -81,6 +81,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -81,6 +81,18 @@ server {
@@ -81,6 +81,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -81,6 +81,18 @@ server {
@@ -81,6 +81,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -81,6 +81,18 @@ server {
@@ -81,6 +81,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -81,6 +81,18 @@ server {
@@ -81,6 +81,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -81,6 +81,18 @@ server {
@@ -81,6 +81,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -71,6 +71,18 @@ server {
@@ -71,6 +71,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -71,6 +71,18 @@ server {
@@ -71,6 +71,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
@@ -71,6 +71,18 @@ server {
@@ -71,6 +71,18 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
}
+ # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites
+ # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get
+ # the same hour.
+ location ^~ /shared/ {
+ alias /opt/openvibe.sites/dist/_shared/;
+ add_header Cache-Control "public, max-age=3600";
+ add_header Access-Control-Allow-Origin "*";
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ }
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
# Only the files in dist/ exist; anything else is a real 404 with a page, never the front page.
error_page 404 /404.html;
error_page 404 /404.html;
new file mode 100644
new file mode 100644
@@ -0,0 +1,684 @@
@@ -0,0 +1,684 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Account Switcher
+// Google-style multi-account management with anonymous mode.
+// Persists sessions in localStorage, coordinates with server
+// for token swapping. Integrates with OpenVibeNavbar dropdown.
+// Usage: OpenVibeAccountSwitcher.init({ apiBase, onSwitch })
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ const STORAGE_KEY = 'openvibe_accounts';
+ const TOKEN_KEY = 'ov_token';
+ const ACTIVE_KEY = 'openvibe_active_account';
+ const ANON_KEY = 'openvibe_anon_token';
+ const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>
+ const MAX_ACCOUNTS = 10;
+
+ let _config = { apiBase: 'https://openvibe.network', onSwitch: null };
+ let _panelEl = null;
+
+ function isAnonId(id) {
+ return String(id) === 'anon' || String(id).startsWith('anon_');
+ }
+
+ /** Store of session tokens per anon identity */
+ function getAnonTokens() {
+ try { return JSON.parse(localStorage.getItem(ANON_TOKENS_KEY) || '{}'); } catch { return {}; }
+ }
+ function saveAnonTokens(tokens) {
+ localStorage.setItem(ANON_TOKENS_KEY, JSON.stringify(tokens));
+ }
+ function setAnonToken(anonId, token) {
+ const tokens = getAnonTokens();
+ tokens[anonId] = token;
+ saveAnonTokens(tokens);
+ }
+ function getAnonToken(anonId) {
+ return getAnonTokens()[anonId] || null;
+ }
+ function removeAnonToken(anonId) {
+ const tokens = getAnonTokens();
+ delete tokens[anonId];
+ saveAnonTokens(tokens);
+ }
+
+ // Cookie policy (CONTRACTS.md): ov_token is host-only everywhere EXCEPT on
+ // openvibe.tools + its subdomains, where a Domain=.openvibe.tools cookie is
+ // shared across the tool satellites.
+ function onToolsDomain() {
+ const host = (typeof location !== 'undefined' && location.hostname) || '';
+ return host === 'openvibe.tools' || host.endsWith('.openvibe.tools');
+ }
+
+ function clearAuthCookie() {
+ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
+ if (onToolsDomain()) {
+ document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools;SameSite=Lax';
+ }
+ }
+
+ function setAuthCookie(token) {
+ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
+ if (onToolsDomain()) {
+ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};domain=.openvibe.tools;SameSite=Lax;Secure`;
+ } else {
+ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};SameSite=Lax${secure}`;
+ }
+ }
+
+ function normalizeAccount(user, token) {
+ const isAnon = !!user?.is_anon || isAnonId(user?.id);
+ const anonNum = user.anon_number || null;
+ // Each anon gets a unique ID based on its anon_number
+ const id = isAnon ? (anonNum ? `anon_${anonNum}` : 'anon') : user.id;
+ return {
+ id,
+ username: user.username,
+ display_name: user.display_name || user.username,
+ avatar_url: user.avatar_url || null,
+ email: user.email || null,
+ is_anon: isAnon,
+ anon_number: anonNum,
+ token,
+ added_at: Date.now(),
+ };
+ }
+
+ function getAnonAccount(id) {
+ if (id) return getAccounts().find(a => a && a.is_anon && String(a.id) === String(id)) || null;
+ return getAccounts().find(a => a && a.is_anon) || null;
+ }
+
+ function getAnonAccounts() {
+ return getAccounts().filter(a => a && a.is_anon);
+ }
+
+ // ─── Account Store ─────────────────────────────────────────
+ function getAccounts() {
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
+ }
+
+ function saveAccounts(accounts) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts));
+ }
+
+ function getActiveId() {
+ return localStorage.getItem(ACTIVE_KEY);
+ }
+
+ function setActiveId(id) {
+ localStorage.setItem(ACTIVE_KEY, id);
+ }
+
+ function addAccount(user, token) {
+ const accounts = getAccounts();
+ const entry = normalizeAccount(user, token);
+ const existing = accounts.findIndex(a => String(a.id) === String(entry.id));
+ if (existing !== -1) {
+ accounts[existing] = entry;
+ } else {
+ if (accounts.length >= MAX_ACCOUNTS) {
+ accounts.shift(); // Remove oldest
+ }
+ accounts.push(entry);
+ }
+ saveAccounts(accounts);
+ return accounts;
+ }
+
+ function removeAccount(id) {
+ const targetId = String(id);
+ const accounts = getAccounts().filter(a => String(a.id) !== targetId);
+ saveAccounts(accounts);
+ // Clean up anon token if removing an anon identity
+ if (isAnonId(targetId)) removeAnonToken(targetId);
+ if (getActiveId() === targetId) {
+ const fallback = accounts.find(a => !a.is_anon) || accounts[0];
+ if (fallback) switchTo(fallback.id);
+ else logout();
+ }
+ return accounts;
+ }
+
+ /** Read ov_token from the cookie (host-only; Domain=.openvibe.tools on tools hosts) */
+ function getCookieToken() {
+ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
+ return m ? decodeURIComponent(m[1]) : null;
+ }
+
+ // ─── API ───────────────────────────────────────────────────
+ async function apiFetch(path, opts = {}) {
+ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
+ const headers = { 'Content-Type': 'application/json', ...opts.headers };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ const res = await fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: 'include' });
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.json();
+ }
+
+ async function switchTo(accountId) {
+ if (isAnonId(accountId)) return switchToAnon(accountId);
+
+ const accounts = getAccounts();
+ const target = accounts.find(a => String(a.id) === String(accountId));
+ if (target?.is_anon) return switchToAnon(accountId);
+ if (!target || !target.token) {
+ // Token expired — need re-login
+ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
+ return;
+ }
+
+ // Validate token is still good
+ try {
+ const headers = { Authorization: `Bearer ${target.token}` };
+ const res = await fetch(`${_config.apiBase}/api/auth/me`, { headers });
+ if (!res.ok) throw new Error('Token invalid');
+ } catch {
+ // Token expired
+ removeAccount(target.id);
+ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
+ return;
+ }
+
+ localStorage.setItem(TOKEN_KEY, target.token);
+ setActiveId(target.id);
+ setAuthCookie(target.token);
+
+ if (_config.onSwitch) _config.onSwitch(target);
+ else window.location.reload();
+ }
+
+ /**
+ * Switch to an anonymous identity. If anonId is provided, switch to that
+ * specific anon. Otherwise use the most recently active or create a new one.
+ */
+ async function switchToAnon(anonId) {
+ async function fetchAnonUser(token) {
+ const res = await fetch(`${_config.apiBase}/api/auth/anon/${encodeURIComponent(token)}`, { credentials: 'include' });
+ if (!res.ok) throw new Error('Anon session invalid');
+ const data = await res.json();
+ return data.user || null;
+ }
+
+ let anonToken = null;
+ let anonUser = null;
+
+ // If switching to a specific anon identity, look up its token
+ if (anonId && anonId !== 'anon') {
+ anonToken = getAnonToken(anonId);
+ }
+
+ // Fallback to legacy single-anon token
+ if (!anonToken) {
+ anonToken = localStorage.getItem(ANON_KEY);
+ }
+
+ // Try to validate the existing token
+ if (anonToken) {
+ try {
+ anonUser = await fetchAnonUser(anonToken);
+ } catch {
+ // Token invalid — clear it
+ if (anonId) removeAnonToken(anonId);
+ localStorage.removeItem(ANON_KEY);
+ anonToken = null;
+ }
+ }
+
+ // Create new anon session if we don't have a valid one
+ if (!anonToken) {
+ try {
+ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{}',
+ credentials: 'include',
+ }).then(async (res) => {
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.json();
+ });
+ anonToken = data.token;
+ anonUser = data.user || null;
+ } catch (err) {
+ console.error('[OpenVibeAccountSwitcher] Failed to create anon session:', err);
+ return;
+ }
+ }
+
+ if (!anonUser) {
+ try {
+ anonUser = await fetchAnonUser(anonToken);
+ } catch (err) {
+ console.error('[OpenVibeAccountSwitcher] Failed to load anon session:', err);
+ return;
+ }
+ }
+
+ // Store the anon account and its token
+ if (anonUser) {
+ const entry = addAccount(anonUser, anonToken);
+ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
+ setAnonToken(realId, anonToken);
+ // Also keep legacy key for backward compat
+ localStorage.setItem(ANON_KEY, anonToken);
+
+ localStorage.removeItem(TOKEN_KEY);
+ setActiveId(realId);
+ clearAuthCookie();
+
+ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number || null });
+ else window.location.reload();
+ }
+ }
+
+ /**
+ * Create a brand-new anonymous identity (even if one already exists).
+ * Useful for the "New Anon Identity" button.
+ */
+ async function createNewAnon() {
+ try {
+ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ force_new: true }),
+ credentials: 'include',
+ }).then(async (res) => {
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.json();
+ });
+ const anonToken = data.token;
+ const anonUser = data.user;
+ if (!anonUser) throw new Error('No user returned');
+
+ const entry = addAccount(anonUser, anonToken);
+ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
+ setAnonToken(realId, anonToken);
+
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.setItem(ANON_KEY, anonToken);
+ setActiveId(realId);
+ clearAuthCookie();
+
+ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number });
+ else window.location.reload();
+ } catch (err) {
+ console.error('[OpenVibeAccountSwitcher] Failed to create new anon:', err);
+ }
+ }
+
+ /** 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);
+ localStorage.removeItem(ACTIVE_KEY);
+ clearAuthCookie();
+ if (_config.onSwitch) _config.onSwitch(null);
+ else window.location.reload();
+ }
+
+ function logoutAll() {
+ serverLogout();
+ localStorage.removeItem(STORAGE_KEY);
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem(ACTIVE_KEY);
+ localStorage.removeItem(ANON_KEY);
+ localStorage.removeItem(ANON_TOKENS_KEY);
+ clearAuthCookie();
+ if (_config.onSwitch) _config.onSwitch(null);
+ else window.location.reload();
+ }
+
+ // ─── Switcher Panel UI ─────────────────────────────────────
+ function injectStyles() {
+ if (document.getElementById('openvibe-switcher-styles')) return;
+ const s = document.createElement('style');
+ s.id = 'openvibe-switcher-styles';
+ s.textContent = `
+ .openvibe-switcher-overlay {
+ position: fixed; inset: 0; z-index: 20000;
+ background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
+ display: flex; align-items: center; justify-content: center;
+ animation: openvibe-sw-fadein .2s ease;
+ }
+ @keyframes openvibe-sw-fadein { from { opacity: 0; } to { opacity: 1; } }
+
+ .openvibe-switcher-panel {
+ width: 380px; max-height: 520px;
+ background: var(--bg-card, #22222c);
+ border: 1px solid var(--border, #333340); border-radius: 16px;
+ box-shadow: 0 20px 60px rgba(0,0,0,0.6);
+ overflow: hidden; animation: openvibe-sw-pop .25s cubic-bezier(.34,1.56,.64,1);
+ }
+ @keyframes openvibe-sw-pop { from { transform: scale(.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
+
+ .openvibe-switcher-header {
+ text-align: center; padding: 20px 16px 12px;
+ border-bottom: 1px solid var(--border, #333340);
+ }
+ .openvibe-switcher-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; color: var(--text-primary, #e0e0e0); }
+ .openvibe-switcher-header p { margin: 0; font-size: 12px; color: var(--text-muted, #707080); }
+
+ .openvibe-switcher-list { padding: 8px; max-height: 300px; overflow-y: auto; }
+
+ .openvibe-switcher-item {
+ display: flex; align-items: center; gap: 12px; padding: 10px 12px;
+ border-radius: 10px; cursor: pointer; transition: background .15s;
+ position: relative;
+ }
+ .openvibe-switcher-item:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-switcher-item.active { background: rgba(192,150,92,0.08); }
+ .openvibe-switcher-item.active::after {
+ content: '✓'; position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
+ color: var(--accent, #3b82f6); font-weight: 700; font-size: 16px;
+ }
+ .openvibe-switcher-item img { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
+ .openvibe-switcher-item .info { flex: 1; line-height: 1.3; }
+ .openvibe-switcher-item .info .name { font-size: 14px; font-weight: 600; color: var(--text-primary, #e0e0e0); }
+ .openvibe-switcher-item .info .sub { font-size: 11px; color: var(--text-muted, #707080); }
+ .openvibe-switcher-item .remove {
+ opacity: 0; font-size: 14px; color: var(--live-red, #e74c3c);
+ cursor: pointer; padding: 4px 6px; border-radius: 4px;
+ transition: opacity .15s, background .15s;
+ }
+ .openvibe-switcher-item:hover .remove { opacity: .6; }
+ .openvibe-switcher-item .remove:hover { opacity: 1; background: rgba(231,76,60,0.1); }
+
+ .openvibe-switcher-anon {
+ display: flex; align-items: center; gap: 12px; padding: 10px 12px;
+ margin: 0 8px 8px; border-radius: 10px; cursor: pointer;
+ border: 1px dashed var(--border, #333340);
+ transition: all .15s; color: var(--text-muted, #707080);
+ }
+ .openvibe-switcher-anon:hover { background: var(--bg-hover, #2f2f3d); border-color: var(--accent-dark, #a07840); }
+ .openvibe-switcher-anon .anon-icon { font-size: 22px; width: 40px; text-align: center; }
+
+ .openvibe-switcher-actions {
+ padding: 8px 12px; border-top: 1px solid var(--border, #333340);
+ display: flex; gap: 8px; justify-content: center;
+ }
+ .openvibe-switcher-actions button {
+ flex: 1; padding: 8px; border: none; border-radius: 8px;
+ font-size: 12px; font-weight: 600; cursor: pointer;
+ transition: all .15s;
+ }
+ .openvibe-switcher-actions .btn-add {
+ background: var(--accent, #3b82f6); color: #fff;
+ }
+ .openvibe-switcher-actions .btn-add:hover { background: var(--accent-dark, #a07840); }
+ .openvibe-switcher-actions .btn-signout {
+ background: rgba(231,76,60,0.1); color: var(--live-red, #e74c3c);
+ }
+ .openvibe-switcher-actions .btn-signout:hover { background: rgba(231,76,60,0.2); }
+ `;
+ document.head.appendChild(s);
+ }
+
+ function showPanel() {
+ if (_panelEl) return;
+ injectStyles();
+
+ const accounts = getAccounts();
+ const activeId = getActiveId();
+ const isAnonActive = isAnonId(activeId);
+ const loggedAccounts = accounts.filter(a => !a.is_anon);
+ const anonAccounts = accounts.filter(a => a.is_anon);
+
+ const overlay = document.createElement('div');
+ overlay.className = 'openvibe-switcher-overlay';
+ overlay.addEventListener('click', e => {
+ if (e.target === overlay) closePanel();
+ });
+
+ const panel = document.createElement('div');
+ panel.className = 'openvibe-switcher-panel';
+
+ panel.innerHTML = `
+ <div class="openvibe-switcher-header">
+ <h3>\uD83D\uDD25 Switch Account</h3>
+ <p>Choose an account or browse anonymously</p>
+ </div>
+ <div class="openvibe-switcher-list">
+ ${loggedAccounts.map(a => `
+ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
+ <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="">
+ <div class="info">
+ <div class="name">${a.display_name || a.username}</div>
+ <div class="sub">${a.email || `@${a.username}`}</div>
+ </div>
+ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}
+ </div>
+ `).join('')}
+
+ ${anonAccounts.length > 0 ? `
+ <div style="padding:6px 12px 2px;font-size:11px;font-weight:600;color:var(--text-muted,#707080);text-transform:uppercase;letter-spacing:0.05em;">Anonymous Identities</div>
+ ${anonAccounts.map(a => `
+ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
+ <span style="width:40px;height:40px;display:flex;align-items:center;justify-content:center;font-size:20px;border-radius:50%;background:var(--bg-secondary,#1a1a26);flex-shrink:0;">\uD83E\uDEE5</span>
+ <div class="info">
+ <div class="name">Anonymous #${a.anon_number || '?'}</div>
+ <div class="sub">anon_${a.anon_number || '?'}</div>
+ </div>
+ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove identity">\u2715</span>` : ''}
+ </div>
+ `).join('')}
+ ` : ''}
+ </div>
+ <div class="openvibe-switcher-anon" id="openvibe-sw-new-anon">
+ <span class="anon-icon">+</span>
+ <div class="info">
+ <div class="name" style="color:var(--text-primary,#e0e0e0)">${anonAccounts.length > 0 ? 'New Anon Identity' : 'Go Anonymous'}</div>
+ <div class="sub">${anonAccounts.length > 0 ? 'Create another anonymous identity' : 'Browse without an account \u00b7 Limited features'}</div>
+ </div>
+ </div>
+ <div class="openvibe-switcher-actions">
+ <button class="btn-add" id="openvibe-sw-add">+ Add Account</button>
+ <button class="btn-signout" id="openvibe-sw-signout">Sign Out All</button>
+ </div>
+ `;
+
+ overlay.appendChild(panel);
+ document.body.appendChild(overlay);
+ _panelEl = overlay;
+
+ // Wire events
+ panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {
+ el.addEventListener('click', e => {
+ if (e.target.closest('.remove')) return;
+ switchTo(el.dataset.id);
+ closePanel();
+ });
+ });
+
+ panel.querySelectorAll('[data-remove-id]').forEach(el => {
+ el.addEventListener('click', e => {
+ e.stopPropagation();
+ removeAccount(el.dataset.removeId);
+ closePanel();
+ showPanel(); // Re-render
+ });
+ });
+
+ panel.querySelector('#openvibe-sw-new-anon').addEventListener('click', () => {
+ if (anonAccounts.length > 0) {
+ createNewAnon();
+ } else {
+ switchToAnon();
+ }
+ closePanel();
+ });
+
+ panel.querySelector('#openvibe-sw-add').addEventListener('click', () => {
+ window.location.href = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
+ });
+
+ panel.querySelector('#openvibe-sw-signout').addEventListener('click', () => {
+ logoutAll();
+ closePanel();
+ });
+
+ // Esc to close
+ const escHandler = e => { if (e.key === 'Escape') closePanel(); };
+ document.addEventListener('keydown', escHandler);
+ overlay._escHandler = escHandler;
+ }
+
+ function closePanel() {
+ if (!_panelEl) return;
+ document.removeEventListener('keydown', _panelEl._escHandler);
+ _panelEl.remove();
+ _panelEl = null;
+ }
+
+ // ─── Token Refresh ───────────────────────────────────────────
+ // Decode JWT payload without verification (client-side can't verify RS256).
+ // Used only to check `exp` claim for proactive refresh.
+ function decodeJwtPayload(token) {
+ try {
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
+ return JSON.parse(atob(payload));
+ } catch { return null; }
+ }
+
+ let _refreshTimer = null;
+ let _refreshInProgress = false;
+
+ /**
+ * Silently refresh the JWT if it expires within the next 2 hours,
+ * or if it has already expired (7-day grace period on the server).
+ * Updates localStorage, cookie, and account list.
+ */
+ async function silentRefresh() {
+ if (_refreshInProgress) return;
+ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
+ if (!token) return;
+ const payload = decodeJwtPayload(token);
+ if (!payload || !payload.exp) return;
+
+ const expiresAt = payload.exp * 1000;
+ const now = Date.now();
+ const twoHours = 2 * 60 * 60 * 1000;
+
+ // Only refresh if token expires within 2 hours or is already expired
+ if (expiresAt - now > twoHours) return;
+
+ _refreshInProgress = true;
+ try {
+ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ credentials: 'include',
+ });
+ if (!res.ok) {
+ // If refresh fails with 401, token is truly dead — don't retry
+ if (res.status === 401) {
+ console.warn('[OpenVibeAuth] Token refresh failed — session expired');
+ }
+ return;
+ }
+ const data = await res.json();
+ if (data.token) {
+ // Update localStorage
+ localStorage.setItem(TOKEN_KEY, data.token);
+ // Update cookie
+ setAuthCookie(data.token);
+ // Update the stored account entry
+ const activeId = getActiveId();
+ if (activeId && !isAnonId(activeId)) {
+ const accounts = getAccounts();
+ const idx = accounts.findIndex(a => String(a.id) === String(activeId));
+ if (idx !== -1) {
+ accounts[idx].token = data.token;
+ saveAccounts(accounts);
+ }
+ }
+ console.log('[OpenVibeAuth] Token refreshed silently');
+ }
+ } catch (err) {
+ console.warn('[OpenVibeAuth] Silent refresh error:', err.message);
+ } finally {
+ _refreshInProgress = false;
+ }
+ }
+
+ /**
+ * Start a periodic timer that checks token expiry and refreshes when needed.
+ * Checks every 15 minutes. Also runs an immediate check.
+ */
+ function startRefreshTimer() {
+ if (_refreshTimer) return;
+ silentRefresh(); // Immediate check
+ _refreshTimer = setInterval(silentRefresh, 15 * 60 * 1000); // Every 15 min
+ }
+
+ function stopRefreshTimer() {
+ if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; }
+ }
+
+ // ─── Listen for navbar events ──────────────────────────────
+ document.addEventListener('openvibe-switch-account', e => {
+ switchTo(e.detail.accountId);
+ });
+
+ // ─── Public API ────────────────────────────────────────────
+ const OpenVibeAccountSwitcher = {
+ init(opts = {}) {
+ Object.assign(_config, opts);
+ // Start silent token refresh for logged-in users
+ const activeId = getActiveId();
+ if (activeId && !isAnonId(activeId)) {
+ startRefreshTimer();
+ }
+ },
+
+ /** Call after login success to store this account. */
+ addAccount,
+ removeAccount,
+ getAccounts,
+ getActiveId,
+ switchTo,
+ switchToAnon,
+ createNewAnon,
+ getAnonAccounts,
+ logout,
+ logoutAll,
+ silentRefresh,
+ startRefreshTimer,
+ stopRefreshTimer,
+
+ /** Show the full-screen account switcher panel. */
+ showPanel,
+ closePanel,
+
+ /** Quick check: is user on an anonymous session? */
+ isAnonymous() {
+ const id = getActiveId();
+ return isAnonId(id);
+ },
+
+ /** Get the current active account object. */
+ getActive() {
+ const id = getActiveId();
+ if (!id) return null;
+ if (isAnonId(id)) return getAnonAccount(id) || getAnonAccount() || null;
+ return getAccounts().find(a => String(a.id) === String(id)) || null;
+ },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeAccountSwitcher;
+ else root.OpenVibeAccountSwitcher = OpenVibeAccountSwitcher;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,445 @@
@@ -0,0 +1,445 @@
+/**
+ * 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 root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});
+ // Where this file was served from: shipped.js is loaded from the same place (the site's own
+ // /shared/ copy, or openvibe.network), so the site's CSP already allows it.
+ const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })();
+ // ── Frame data (https://openvibe.network/api/frame) ─────────────────────────────
+ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
+ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
+ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
+ const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {
+ const KEY = 'ov_frame_v1', TTL = 30 * 60000;
+ let inflight = null;
+ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
+ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
+ function clean(d) {
+ if (!d || !Array.isArray(d.nav)) return null;
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const f = d.footer || {}, lg = f.legal || {};
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
+ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
+ }
+ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
+ function refresh() {
+ if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call out
+ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
+ inflight = fetch('https://openvibe.network/api/frame?host=' + encodeURIComponent(host()), { credentials: 'omit' })
+ .then(r => (r.ok ? r.json() : null)).then(clean)
+ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
+ .catch(() => null);
+ return inflight;
+ }
+ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
+ function get(onFirst) {
+ const c = cached();
+ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
+ return c ? c.data : null;
+ }
+ return { get, refresh };
+ })());
+
+ const NETWORK_URL = 'https://openvibe.network';
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = [
+ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
+ { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
+ { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
+ ];
+
+ const SOCIAL = [
+ { name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' },
+ { name: 'Discord', icon: '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',
+ shipped: true, // the "shipped X ago" line (openvibe-shared/shipped.js)
+ updates: null, // this site's update log; default: openvibe.network/updates?site=<host>
+ };
+
+ 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';
+ if (/(^|\.)openre\.stream$/.test(h)) return 'openre';
+ const tld = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);
+ if (tld && tld[1] !== 'tools' && TLD_LABELS[tld[1]]) return tld[1];
+ 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) {
+ // Each site answers for itself: the Frame service names this domain's own documents.
+ const frame = cfg._frame;
+ if (frame && frame.footer.legal && !cfg.legalBase) return frame.footer.legal[item.key];
+ // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
+ if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
+ if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
+ return item.fallback;
+ }
+
+ // ── Icons ──────────────────────────────────────────────────
+ // The footer ships its own icons. It used to borrow the page's Font Awesome, so on pages that do not
+ // load that font (the server-rendered Tools and legal pages) every icon was an empty box.
+ const ICONS = {
+ github: '<path fill="currentColor" stroke="none" d="M12 2.2a9.8 9.8 0 0 0-3.1 19.1c.5.1.7-.2.7-.5v-1.7c-2.7.6-3.3-1.2-3.3-1.2-.4-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.2-.2-4.5-1.1-4.5-4.8 0-1.1.4-1.9 1-2.6-.1-.2-.4-1.2.1-2.6 0 0 .8-.3 2.7 1a9.3 9.3 0 0 1 4.9 0c1.9-1.3 2.7-1 2.7-1 .5 1.4.2 2.4.1 2.6.6.7 1 1.5 1 2.6 0 3.7-2.3 4.6-4.5 4.8.4.3.7.9.7 1.800v2.700c0 .3.2.6.7.5A9.8 9.8 0 0 0 12 2.200z"/>',
+ discord: '<path fill="currentColor" stroke="none" d="M19.3 5.300a16.5 16.5 0 0 0-4.1-1.300l-.5 1a15.3 15.3 0 0 0-5.4 0l-.5-1a16.5 16.5 0 0 0-4.1 1.300C2.1 9.2 1.4 13 1.7 16.700a16.6 16.6 0 0 0 5.1 2.600l1.1-1.800c-.6-.2-1.2-.5-1.7-.8l.4-.3a11.8 11.8 0 0 0 10.8 0l.4.3c-.5.3-1.1.6-1.7.8l1.1 1.800a16.6 16.6 0 0 0 5.1-2.600c.4-4.3-.7-8.1-3-11.400zM8.7 14.400c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100zm6.6 0c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100z"/>',
+ sitemap: '<rect x="9" y="3.5" width="6" height="4.5" rx="1"/><rect x="3" y="16" width="6" height="4.5" rx="1"/><rect x="15" y="16" width="6" height="4.5" rx="1"/><path d="M12 8v4M6 16v-2.500h12V16"/>',
+ arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
+ channel: '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.800a6 6 0 0 0 0 8.400M16.2 7.800a6 6 0 0 1 0 8.400M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',
+ dashboard: '<path d="M4.5 17a8 8 0 1 1 15 0M12 13l3.5-4"/><circle cx="12" cy="13.5" r="1.3"/>',
+ account: '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>',
+ themes: '<path d="M12 4.500a7.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.300h1.600a3.2 3.2 0 0 0 3.2-3.200C19.5 8 16.2 4.5 12 4.500z"/><circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',
+ tools: '<path d="M14.5 5.500a4 4 0 0 0-5.2 5.200L4.5 15.500a1.8 1.8 0 0 0 2.5 2.500l4.8-4.800a4 4 0 0 0 5.2-5.200l-2.6 2.6-2.1-.5-.5-2.100z"/>',
+ };
+ const icon = (name) => `<svg class="ovf-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.800" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${(ICONS[name] || ICONS.arrow)}</svg>`;
+
+ // ── Markup ─────────────────────────────────────────────────
+ function linkTag(raw) {
+ // Items are { name, url }; { label, href } (the navbar's shape) is read the same, so a site that
+ // passes it does not get empty links.
+ const item = { ...raw, name: raw.name ?? raw.label, url: raw.url ?? raw.href };
+ // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.
+ const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);
+ const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : '';
+ const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
+ const title = item.desc ? ` title="${esc(item.desc)}"` : '';
+ return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
+ }
+
+ /** The "🚀 shipped X ago: …" line, filled by shipped.js from the network changelog; hidden until then. */
+ function shippedLine(c) {
+ if (c.shipped === false) return '';
+ const host = typeof location !== 'undefined' ? location.hostname : '';
+ const updates = c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;
+ return `<a class="ovf-shipped" data-ov-shipped="latest" data-service="${esc(c.service || 'auto')}" href="${esc(updates)}" hidden></a>`;
+ }
+ function updatesHref(c) {
+ const host = typeof location !== 'undefined' ? location.hostname : '';
+ return c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;
+ }
+
+ function buildHTML(cfg) {
+ const c = { ...DEFAULTS, ...(cfg || {}) };
+ const service = c.service || detectService();
+ const brand = c.brandName || brandFor(service);
+ const frame = c._frame = OVFrame.get(() => { try { render(); } catch { /* */ } });
+ // Most used sites first when the Frame service has spoken; the built-in list otherwise.
+ const network = frame && frame.nav.length
+ ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))
+ : NETWORK.filter(n => n.id !== service);
+ const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline;
+ const popular = frame ? frame.footer.popular : [];
+ const discover = frame ? frame.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];
+ 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>
+ ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}
+ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
+ </nav>
+ ${shippedLine(c)}
+ <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(tagline)}</p>
+ <div class="ovf-social">
+ ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}">${icon(s.icon)}</a>`).join('')}
+ <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap">${icon('sitemap')}</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 ${icon('arrow')}</a>
+ </nav>` : ''}
+ ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">
+ <h3>Popular tools</h3>
+ ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
+ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>
+ </nav>` : ''}
+ <nav class="ovf-col" aria-label="Legal">
+ <h3>Legal</h3>
+ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
+ </nav>
+ </div>
+ <div class="ovf-account" id="ovf-account" hidden></div>
+ <div class="ovf-bar">
+ <span class="ovf-copy">Open source & community driven · built in the open</span>
+ ${shippedLine(c)}
+ <span class="ovf-legal-inline">
+ ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}
+ ${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: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' },
+ { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'account' },
+ { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' },
+ { name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' },
+ ];
+ 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"' : ''}>${icon(q.icon)} ${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,#60a5fa);transform:translateX(2px)}
+.ovf-more{font-weight:600;opacity:.85}.ovf-ic{flex:none;vertical-align:-.14em}.ovf-more .ovf-ic{font-size:.85em;margin-left:3px;transition:transform .15s}.ovf-more:hover .ovf-ic{transform:translateX(3px)}.ovf-social .ovf-ic{font-size:17px}.ovf-account-links .ovf-ic{font-size:1.05em;opacity:.85}
+.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,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-2px)}
+.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;
+ margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;
+ background:color-mix(in srgb, var(--accent,#60a5fa) 8%, var(--bg-tertiary,#1a1b28));
+ border:1px solid color-mix(in srgb, var(--accent,#60a5fa) 26%, var(--border,rgba(255,255,255,.08)));
+ animation:ovfIn .4s ease-out both}
+@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
+.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}
+.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}
+.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;
+ background:var(--accent,#60a5fa);color:#fff;font-size:.7rem;font-weight:800;flex:none}
+.ovf-avatar img{width:100%;height:100%;object-fit:cover}
+.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}
+.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;
+ color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));
+ text-decoration:none;transition:color .15s,border-color .15s,transform .15s}
+.ovf-account-links a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-1px)}
+.ovf-account-links i{font-size:.72rem;opacity:.8}
+.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;
+ margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));
+ 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 [hidden],.ovf-account[hidden]{display:none!important}
+.ovf .ovf-shipped.ov-shipped-latest{font-size:.76rem;padding:4px 12px;max-width:min(520px,100%);background:transparent}
+.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,#60a5fa)}
+.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}
+@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
+@media (max-width:560px){
+ .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}}
+.ovf{-webkit-tap-highlight-color:transparent}
+:where(.ovf) :where(a,button):active{scale:.97}
+:where(.ovf) :where(a,button):focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:2px;border-radius:4px}
+@media (prefers-reduced-motion:reduce){:where(.ovf) :where(a,button):active{scale:none;opacity:.82}}`;
+
+ 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);
+ if (_cfg.shipped !== false) loadShipped();
+ }
+
+ // shipped.js mounts every [data-ov-shipped] element when it loads; loaded once, lazily, after the footer.
+ let _shippedLoading = false;
+ function loadShipped() {
+ if (typeof document === 'undefined') return;
+ if (root.OpenVibeShipped && typeof root.OpenVibeShipped.scan === 'function') { try { root.OpenVibeShipped.scan(document); } catch { /* */ } return; }
+ if (_shippedLoading || document.querySelector('script[data-ov-shipped-src]')) return;
+ _shippedLoading = true;
+ const sc = document.createElement('script');
+ let src = `${NETWORK_URL}/shared/shipped.js`;
+ try { if (OWN_SRC) src = new URL('shipped.js', OWN_SRC).href; } catch { /* */ }
+ sc.src = src;
+ sc.defer = true;
+ sc.setAttribute('data-ov-shipped-src', '');
+ (document.head || document.documentElement).appendChild(sc);
+ }
+
+ 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();
+ }
+
+ /**
+ * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for
+ * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element.
+ * ssr({ service, variant, links, mountId }) → '<style>…</style><footer id="ov-footer" …>…</footer>'
+ */
+ function ssr(cfg = {}) {
+ const c = { ...DEFAULTS, ...cfg };
+ const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, '');
+ return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;
+ }
+
+ return { init, ssr, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — navbar icon glyphs. Generated by scripts/build-nav-icons.py; do not edit by hand.
+// Font Awesome Free 6 solid glyphs (CC BY 4.0, fontawesome.com/license/free) as filled paths:
+// name -> [advance width, path] in a 0 0 <w> 512 viewBox. navbar.js ships the glyphs it draws
+// itself and fetches this file after first paint for the rest; a page that wants every glyph
+// from the first frame can load it before navbar.js instead.
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ const NAV_ICONS = {
+ 'fa-bell': [448, 'M224 11Q210 11 201 20Q192 29 192 43V62Q136 74 101 117Q65 160 64 219V238Q63 310 16 365L8 374Q-5 389 3 408Q12 426 32 427H416Q436 426 445 408Q453 389 440 374L433 365Q385 311 384 238V219Q383 160 347 117Q312 74 256 62V43Q256 29 247 20Q238 11 224 11ZM269 504Q288 485 288 459H224H160Q160 485 179 504Q198 523 224 523Q250 523 269 504Z'],
+ 'fa-book': [448, 'M96 11Q55 12 28 39Q1 66 0 107V427Q1 468 28 495Q55 522 96 523H384H416Q430 523 439 514Q448 505 448 491Q448 477 439 468Q430 459 416 459V395Q430 395 439 386Q448 377 448 363V43Q448 29 439 20Q430 11 416 11H384ZM96 395H352H96H352V459H96Q82 459 73 450Q64 441 64 427Q64 413 73 404Q82 395 96 395ZM128 155Q129 140 144 139H336Q351 140 352 155Q351 170 336 171H144Q129 170 128 155ZM144 203H336H144H336Q351 204 352 219Q351 234 336 235H144Q129 234 128 219Q129 204 144 203Z'],
+ 'fa-camera': [512, 'M149 76 139 107 149 76 139 107H64Q37 108 19 126Q1 144 0 171V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V171Q511 144 493 126Q475 108 448 107H373L363 76Q351 45 317 43H195Q161 45 149 76ZM256 203Q282 203 304 216Q326 229 339 251Q352 274 352 299Q352 324 339 347Q326 369 304 382Q282 395 256 395Q230 395 208 382Q186 369 173 347Q160 324 160 299Q160 274 173 251Q186 229 208 216Q230 203 256 203Z'],
+ 'fa-clock-rotate-left': [512, 'M75 86 41 52 75 86 41 52Q29 41 15 47Q1 52 0 69V179Q2 201 24 203H134Q151 202 156 188Q162 174 151 162L120 131Q174 77 256 75Q338 77 392 131Q446 185 448 267Q446 349 392 403Q338 457 256 459Q194 458 146 425Q135 417 122 419Q110 422 102 433Q94 444 96 456Q99 469 110 477Q173 522 256 523Q328 522 385 488Q443 454 477 396Q511 339 512 267Q511 195 477 138Q443 80 385 46Q328 12 256 11Q203 11 156 31Q110 51 75 86ZM256 139Q234 141 232 163V267Q232 277 239 284L311 356Q328 370 345 356Q359 339 345 322L280 257V163Q278 141 256 139Z'],
+ 'fa-coins': [512, 'M512 91Q511 118 474 139Q428 164 351 170Q346 167 340 165Q279 140 192 139Q180 139 168 140L166 139Q129 118 128 91Q130 57 184 34Q238 12 320 11Q402 12 456 34Q510 57 512 91ZM161 172Q176 171 192 171Q239 171 278 180Q318 188 345 202Q383 223 384 251Q384 257 382 263Q374 283 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q346 299 346 299Q346 299 346 299Q319 314 279 322Q240 331 192 331Q100 330 44 302Q41 300 38 299Q1 278 0 251Q1 224 36 204Q71 184 128 176Q144 173 161 172ZM416 251Q414 218 392 198Q435 191 468 177Q493 167 512 152V187Q511 217 468 238Q446 249 416 256Q416 256 416 256Q416 254 416 251ZM384 347Q383 374 346 395Q344 396 342 397Q341 397 340 398Q284 426 192 427Q144 427 105 418Q65 410 38 395Q1 374 0 347V312Q19 327 44 337Q105 362 192 363Q279 362 340 337Q352 332 363 326Q372 321 380 315Q381 314 383 313Q383 312 384 312V315V321V347ZM416 347V315V347V315V289Q445 283 468 273Q493 263 512 248V283Q512 299 497 314Q471 339 416 352Q416 350 416 347ZM192 459Q279 458 340 433Q365 423 384 408V443Q382 477 328 500Q274 522 192 523Q110 522 56 500Q2 477 0 443V408Q19 423 44 433Q105 458 192 459Z'],
+ 'fa-comments': [640, 'M208 363Q296 361 355 311Q414 262 416 187Q414 112 355 63Q296 13 208 11Q120 13 61 63Q2 112 0 187Q1 246 40 290Q34 305 25 315Q18 324 12 329Q9 332 8 333Q7 334 7 334H6Q-2 341 1 352Q5 362 16 363Q50 362 78 351Q92 345 103 339Q150 362 208 363ZM448 187Q445 273 384 329Q322 385 232 394Q251 451 305 486Q359 522 432 523Q490 522 537 499Q548 505 562 511Q590 522 624 523Q635 522 639 512Q642 501 633 494Q633 494 633 493Q632 493 632 493Q631 492 628 489Q622 484 615 475Q606 464 600 450Q639 406 640 347Q638 276 584 227Q530 178 447 171Q448 179 448 187Z'],
+ 'fa-gamepad': [640, 'M192 75Q110 77 56 131Q2 185 0 267Q2 349 56 403Q110 457 192 459H448Q530 457 584 403Q638 349 640 267Q638 185 584 131Q530 77 448 75H192ZM496 179Q519 180 531 199Q541 219 531 239Q519 258 496 259Q473 258 461 239Q451 219 461 199Q473 180 496 179ZM392 315Q393 292 412 280Q432 270 452 280Q471 292 472 315Q471 338 452 350Q432 360 412 350Q393 338 392 315ZM168 211Q170 189 192 187Q214 189 216 211V243H248Q270 245 272 267Q270 289 248 291H216V323Q214 345 192 347Q170 345 168 323V291H136Q114 289 112 267Q114 245 136 243H168V211Z'],
+ 'fa-gauge-high': [512, 'M0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267ZM288 107Q288 93 279 84Q270 75 256 75Q242 75 233 84Q224 93 224 107Q224 121 233 130Q242 139 256 139Q270 139 279 130Q288 121 288 107ZM256 427Q283 426 301 408Q319 390 320 363Q319 336 302 318L366 173Q373 152 354 141Q333 134 322 153L258 299Q257 299 257 299Q256 299 256 299Q229 300 211 318Q193 336 192 363Q193 390 211 408Q229 426 256 427ZM176 155Q176 141 167 132Q158 123 144 123Q130 123 121 132Q112 141 112 155Q112 169 121 178Q130 187 144 187Q158 187 167 178Q176 169 176 155ZM96 299Q110 299 119 290Q128 281 128 267Q128 253 119 244Q110 235 96 235Q82 235 73 244Q64 253 64 267Q64 281 73 290Q82 299 96 299ZM448 267Q448 253 439 244Q430 235 416 235Q402 235 393 244Q384 253 384 267Q384 281 393 290Q402 299 416 299Q430 299 439 290Q448 281 448 267Z'],
+ 'fa-hand-fist': [448, 'M192 11Q206 11 215 20Q224 29 224 43V155H160V43Q160 29 169 20Q178 11 192 11ZM64 75Q64 61 73 52Q82 43 96 43Q110 43 119 52Q128 61 128 75V155H64V75ZM256 75Q256 61 265 52Q274 43 288 43Q302 43 311 52Q320 61 320 75V171Q320 185 311 194Q302 203 288 203Q274 203 265 194Q256 185 256 171V75ZM352 139Q352 125 361 116Q370 107 384 107Q398 107 407 116Q416 125 416 139V203Q416 217 407 226Q398 235 384 235Q370 235 361 226Q352 217 352 203V139ZM256 227V226V227V226Q270 235 288 235Q308 235 324 224Q330 243 347 255Q363 267 384 267Q402 267 416 258V267Q416 307 399 340Q382 373 352 395V491Q352 505 343 514Q334 523 320 523H160Q146 523 137 514Q128 505 128 491V413Q102 401 81 380L70 369Q33 331 32 278V251Q33 224 51 206Q69 188 96 187H184Q201 187 212 199Q224 210 224 227Q224 244 212 255Q201 267 184 267H128Q113 268 112 283Q113 298 128 299H184Q215 298 235 278Q255 258 256 227Z'],
+ 'fa-house': [576, 'M576 267Q575 280 566 289Q557 298 544 299H512L513 459Q512 463 512 467V483Q512 500 500 511Q489 523 472 523H456Q454 523 453 523Q451 523 449 523H416H392Q375 523 364 511Q352 500 352 483V459V395Q352 381 343 372Q334 363 320 363H256Q242 363 233 372Q224 381 224 395V459V483Q224 500 212 511Q201 523 184 523H160H128Q127 523 126 523Q125 523 124 523Q122 523 120 523H104Q87 523 76 511Q64 500 64 483V371Q64 370 64 368V299H32Q18 298 9 289Q0 280 0 267Q0 253 10 243L266 19Q277 10 288 11Q300 11 309 18L565 243Q577 253 576 267Z'],
+ 'fa-id-card': [576, 'M0 107H576H0H576Q575 80 557 62Q539 44 512 43H64Q37 44 19 62Q1 80 0 107ZM0 139V427V139V427Q1 454 19 472Q37 490 64 491H512Q539 490 557 472Q575 454 576 427V139H0ZM64 416Q65 394 80 379Q95 364 117 363H235Q257 364 272 379Q287 394 288 416Q287 426 277 427H75Q65 426 64 416ZM176 203Q212 204 231 235Q249 267 231 299Q212 330 176 331Q140 330 121 299Q103 267 121 235Q140 204 176 203ZM352 219Q353 204 368 203H496Q511 204 512 219Q511 234 496 235H368Q353 234 352 219ZM352 283Q353 268 368 267H496Q511 268 512 283Q511 298 496 299H368Q353 298 352 283ZM352 347Q353 332 368 331H496Q511 332 512 347Q511 362 496 363H368Q353 362 352 347Z'],
+ 'fa-link': [640, 'M580 279Q622 234 622 176Q622 119 580 74Q541 37 489 32Q438 28 394 59L392 60Q381 68 379 81Q377 93 385 105Q393 115 405 117Q418 120 429 112L431 111Q456 94 484 96Q513 98 535 119Q558 144 558 176Q558 209 535 233L422 346Q397 369 365 369Q333 369 308 346Q287 324 285 295Q283 267 300 242L301 240Q308 229 306 217Q304 204 293 196Q282 188 270 190Q257 192 249 203L248 205Q217 249 221 301Q225 352 263 391Q308 433 365 433Q423 433 468 391L580 279ZM60 255Q18 300 18 358Q18 415 60 460Q99 497 151 502Q202 506 247 475L248 474Q259 466 261 453Q263 441 256 429Q247 419 235 417Q222 414 211 422L209 423Q184 440 156 438Q127 436 105 415Q82 390 82 358Q82 325 106 301L218 188Q243 165 275 165Q307 165 332 188Q353 210 355 239Q357 267 340 292L339 294Q332 305 334 318Q336 330 347 338Q358 346 370 344Q383 342 391 331L392 329Q423 285 419 233Q415 182 377 143Q332 101 275 101Q217 101 173 143L60 255Z'],
+ 'fa-list-check': [512, 'M152 49Q167 65 154 83L82 163Q75 171 65 171Q54 171 47 164L7 124Q-7 107 7 90Q24 76 41 90L63 112L118 51Q134 36 152 49ZM152 209Q167 225 154 243L82 323Q75 331 65 331Q54 331 47 324L7 284Q-7 267 7 250Q24 236 41 250L63 272L118 211Q134 196 152 209ZM224 107Q224 93 233 84Q242 75 256 75H480Q494 75 503 84Q512 93 512 107Q512 121 503 130Q494 139 480 139H256Q242 139 233 130Q224 121 224 107ZM224 267Q224 253 233 244Q242 235 256 235H480Q494 235 503 244Q512 253 512 267Q512 281 503 290Q494 299 480 299H256Q242 299 233 290Q224 281 224 267ZM160 427Q160 413 169 404Q178 395 192 395H480Q494 395 503 404Q512 413 512 427Q512 441 503 450Q494 459 480 459H192Q178 459 169 450Q160 441 160 427ZM48 379Q75 380 90 403Q102 427 90 451Q75 474 48 475Q21 474 6 451Q-6 427 6 403Q21 380 48 379Z'],
+ 'fa-network-wired': [640, 'M256 75H384H256H384V139H256V75ZM240 11Q220 12 206 25Q193 39 192 59V155Q193 175 206 189Q220 202 240 203H288V235H32Q18 235 9 244Q0 253 0 267Q0 281 9 290Q18 299 32 299H128V331H80Q60 332 46 345Q33 359 32 379V475Q33 495 46 509Q60 522 80 523H240Q260 522 274 509Q287 495 288 475V379Q287 359 274 345Q260 332 240 331H192V299H448V331H400Q380 332 366 345Q353 359 352 379V475Q353 495 366 509Q380 522 400 523H560Q580 522 594 509Q607 495 608 475V379Q607 359 594 345Q580 332 560 331H512V299H608Q622 299 631 290Q640 281 640 267Q640 253 631 244Q622 235 608 235H352V203H400Q420 202 434 189Q447 175 448 155V59Q447 39 434 25Q420 12 400 11H240ZM96 459V395V459V395H224V459H96ZM416 395H544H416H544V459H416V395Z'],
+ 'fa-palette': [512, 'M512 267Q512 268 512 268Q512 269 512 270Q511 297 490 314Q470 331 442 331H344Q324 332 310 345Q297 359 296 379Q296 384 297 389Q300 403 307 417Q307 418 308 419Q318 439 320 461Q320 485 305 503Q291 521 267 523Q261 523 256 523Q184 522 127 488Q69 454 35 396Q1 339 0 267Q1 195 35 138Q69 80 127 46Q184 12 256 11Q328 12 385 46Q443 80 477 138Q511 195 512 267ZM128 299Q128 285 119 276Q110 267 96 267Q82 267 73 276Q64 285 64 299Q64 313 73 322Q82 331 96 331Q110 331 119 322Q128 313 128 299ZM128 203Q142 203 151 194Q160 185 160 171Q160 157 151 148Q142 139 128 139Q114 139 105 148Q96 157 96 171Q96 185 105 194Q114 203 128 203ZM288 107Q288 93 279 84Q270 75 256 75Q242 75 233 84Q224 93 224 107Q224 121 233 130Q242 139 256 139Q270 139 279 130Q288 121 288 107ZM384 203Q398 203 407 194Q416 185 416 171Q416 157 407 148Q398 139 384 139Q370 139 361 148Q352 157 352 171Q352 185 361 194Q370 203 384 203Z'],
+ 'fa-paste': [512, 'M160 11Q123 13 105 43H48Q28 44 14 57Q1 71 0 91V411Q1 431 14 445Q28 458 48 459H192V187Q193 153 215 130Q238 108 272 107H320V91Q319 71 306 57Q292 44 272 43H215Q197 13 160 11ZM272 139Q252 140 238 153Q225 167 224 187V459V475Q225 495 238 509Q252 522 272 523H464Q484 522 498 509Q511 495 512 475V255Q512 235 498 221L430 153Q416 139 396 139H320ZM160 51Q182 53 184 75Q182 97 160 99Q138 97 136 75Q138 53 160 51Z'],
+ 'fa-people-arrows': [640, 'M64 75Q65 39 96 20Q128 2 160 20Q191 39 192 75Q191 111 160 130Q128 148 96 130Q65 111 64 75ZM26 244Q29 213 51 192Q74 172 106 171H150Q193 173 216 205Q212 208 208 211L144 275Q128 293 128 315Q128 337 144 355L192 402V475Q191 495 178 509Q164 522 144 523H112Q92 522 78 509Q65 495 64 475V359Q44 352 32 334Q20 316 22 294L26 244ZM448 75Q449 39 480 20Q512 2 544 20Q575 39 576 75Q575 111 544 130Q512 148 480 130Q449 111 448 75ZM432 211Q428 208 424 205Q448 173 490 171H534Q566 172 589 192Q611 213 614 244L618 294Q620 316 608 334Q596 352 576 359V475Q575 495 562 509Q548 522 528 523H496Q476 522 462 509Q449 495 448 475V402L496 355Q512 337 512 315Q512 293 496 275L432 211ZM272 251V283V251V283H368V251Q369 235 383 229Q397 223 409 234L473 298Q487 315 473 332L409 396Q397 406 383 401Q369 395 368 379V347H272V379Q271 395 257 401Q243 407 231 396L167 332Q153 315 167 298L231 234Q243 224 257 229Q271 236 272 251Z'],
+ 'fa-people-group': [640, 'M72 99Q73 67 100 51Q128 35 156 51Q183 67 184 99Q183 131 156 147Q128 163 100 147Q73 131 72 99ZM64 257Q49 274 48 299Q49 324 64 341V257ZM208 207Q162 249 160 315Q161 367 192 406V427Q192 441 183 450Q174 459 160 459H96Q82 459 73 450Q64 441 64 427V400Q35 386 18 360Q0 333 0 299Q1 251 33 220Q64 188 112 187H144Q181 188 208 207ZM448 427V406V427V406Q479 367 480 315Q478 249 432 207Q459 188 496 187H528Q576 188 607 220Q639 251 640 299Q640 333 622 360Q605 386 576 400V427Q576 441 567 450Q558 459 544 459H480Q466 459 457 450Q448 441 448 427ZM456 99Q457 67 484 51Q512 35 540 51Q567 67 568 99Q567 131 540 147Q512 163 484 147Q457 131 456 99ZM576 257V341V257V341Q592 324 592 299Q592 274 576 257ZM320 43Q356 44 375 75Q393 107 375 139Q356 170 320 171Q284 170 265 139Q247 107 265 75Q284 44 320 43ZM240 315Q241 340 256 357V273Q241 290 240 315ZM384 273V357V273V357Q400 340 400 315Q400 290 384 273ZM448 315Q448 349 430 376Q413 402 384 416V459Q384 473 375 482Q366 491 352 491H288Q274 491 265 482Q256 473 256 459V416Q227 402 210 376Q192 349 192 315Q193 267 225 236Q256 204 304 203H336Q384 204 415 236Q447 267 448 315Z'],
+ 'fa-plus': [448, 'M256 91Q256 77 247 68Q238 59 224 59Q210 59 201 68Q192 77 192 91V235H48Q34 235 25 244Q16 253 16 267Q16 281 25 290Q34 299 48 299H192V443Q192 457 201 466Q210 475 224 475Q238 475 247 466Q256 457 256 443V299H400Q414 299 423 290Q432 281 432 267Q432 253 423 244Q414 235 400 235H256V91Z'],
+ 'fa-right-from-bracket': [512, 'M378 117 501 240 378 117 501 240Q512 251 512 267Q512 283 501 294L378 417Q368 427 354 427Q340 427 330 417Q320 407 320 393V331H192Q178 331 169 322Q160 313 160 299V235Q160 221 169 212Q178 203 192 203H320V141Q320 127 330 117Q340 107 354 107Q368 107 378 117ZM160 107H96H160H96Q82 107 73 116Q64 125 64 139V395Q64 409 73 418Q82 427 96 427H160Q174 427 183 436Q192 445 192 459Q192 473 183 482Q174 491 160 491H96Q55 490 28 463Q1 436 0 395V139Q1 98 28 71Q55 44 96 43H160Q174 43 183 52Q192 61 192 75Q192 89 183 98Q174 107 160 107Z'],
+ 'fa-scissors': [512, 'M256 203 217 164 256 203 217 164Q224 145 224 123Q223 75 191 44Q160 12 112 11Q64 12 33 44Q1 75 0 123Q1 171 33 202Q64 234 112 235Q134 235 153 228L192 267L153 307Q134 299 112 299Q64 300 33 332Q1 363 0 411Q1 459 33 490Q64 522 112 523Q160 522 191 490Q223 459 224 411Q224 389 217 371L499 88Q510 75 499 62Q477 41 448 41Q419 41 397 62L256 203ZM279 354 397 472 279 354 397 472Q419 493 448 493Q477 493 499 472Q510 459 499 446L343 290L279 354ZM64 123Q65 96 88 81Q112 69 136 81Q159 96 160 123Q159 150 136 165Q112 177 88 165Q65 150 64 123ZM112 363Q139 364 154 387Q166 411 154 435Q139 458 112 459Q85 458 70 435Q58 411 70 387Q85 364 112 363Z'],
+ 'fa-screwdriver-wrench': [512, 'M79 16Q62 5 47 18L7 58Q-6 73 5 90L85 194Q92 203 104 203H158L267 312Q256 334 260 359Q263 383 281 402L393 514Q403 523 416 523Q429 523 439 514L503 450Q512 440 512 427Q512 414 503 404L391 292Q372 274 348 271Q323 267 301 278L192 169V115Q192 103 183 96L79 16ZM20 407Q0 427 0 455Q1 484 20 503Q39 522 68 523Q96 523 116 503L234 385Q222 353 230 320L168 258L20 407ZM512 155Q512 139 509 125Q506 116 499 114Q491 113 485 119L421 182Q416 187 409 187H352Q337 186 336 171V114Q336 107 341 102L405 38Q411 32 409 24Q407 17 399 14Q384 11 368 11Q307 13 266 53Q226 94 224 155V156L309 241Q337 234 364 241Q392 249 413 270L429 286Q466 268 489 233Q511 199 512 155ZM56 443Q58 421 80 419Q102 421 104 443Q102 465 80 467Q58 465 56 443Z'],
+ 'fa-shield-halved': [512, 'M256 11Q263 11 269 14L458 94Q475 101 485 116Q496 131 496 151Q497 202 479 271Q461 340 414 405Q368 471 282 514Q256 526 230 514Q144 471 98 405Q51 340 33 271Q15 202 16 151Q16 131 27 116Q37 101 54 94L243 14Q249 11 256 11ZM256 78V456V78V456Q324 421 363 367Q401 313 417 256Q432 198 432 152L256 78Z'],
+ 'fa-shirt': [640, 'M212 11Q224 12 229 24Q238 54 263 72Q287 90 320 91Q353 90 377 72Q402 54 412 24Q416 12 428 11H441Q475 11 502 33L629 138Q639 147 640 161Q641 174 632 184L576 248Q567 258 555 259Q542 260 532 252L480 209V459Q479 486 461 504Q443 522 416 523H224Q197 522 179 504Q161 486 160 459V209L109 252Q98 260 86 259Q73 258 64 248L8 184Q-1 174 0 161Q1 147 12 138L138 33Q165 11 199 11H212Z'],
+ 'fa-text-height': [576, 'M64 139V107V139V107H128V427H96Q82 427 73 436Q64 445 64 459Q64 473 73 482Q82 491 96 491H224Q238 491 247 482Q256 473 256 459Q256 445 247 436Q238 427 224 427H192V107H256V139Q256 153 265 162Q274 171 288 171Q302 171 311 162Q320 153 320 139V91Q319 71 306 57Q292 44 272 43H160H48Q28 44 14 57Q1 71 0 91V139Q0 153 9 162Q18 171 32 171Q46 171 55 162Q64 153 64 139ZM503 52Q493 43 480 43Q467 43 457 52L393 116Q379 132 386 151Q395 170 416 171H448V363H416Q395 364 386 383Q379 402 393 418L457 482Q467 491 480 491Q493 491 503 482L567 418Q581 402 574 383Q565 364 544 363H512V171H544Q565 170 574 151Q581 132 567 116L503 52Z'],
+ 'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],
+ 'fa-user': [448, 'M224 267Q259 267 288 250Q317 233 335 203Q352 173 352 139Q352 105 335 75Q317 45 288 28Q259 11 224 11Q189 11 160 28Q131 45 113 75Q96 105 96 139Q96 173 113 203Q131 233 160 250Q189 267 224 267ZM178 315Q103 317 52 367Q2 418 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q446 418 396 367Q345 317 270 315H178Z'],
+ 'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],
+ 'fa-video': [576, 'M0 139Q1 112 19 94Q37 76 64 75H320Q347 76 365 94Q383 112 384 139V395Q383 422 365 440Q347 458 320 459H64Q37 458 19 440Q1 422 0 395V139ZM559 111Q575 120 576 139V395Q575 414 559 423Q542 432 526 422L430 358L416 348V331V203V186L430 176L526 112Q542 103 559 111Z'],
+ 'fa-wallet': [512, 'M64 43Q37 44 19 62Q1 80 0 107V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V203Q511 176 493 158Q475 140 448 139H80Q65 138 64 123Q65 108 80 107H448Q462 107 471 98Q480 89 480 75Q480 61 471 52Q462 43 448 43H64ZM416 283Q430 283 439 292Q448 301 448 315Q448 329 439 338Q430 347 416 347Q402 347 393 338Q384 329 384 315Q384 301 393 292Q402 283 416 283Z'],
+ 'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],
+ 'fa-arrow-up-right-from-square': [512, 'M320 11Q306 11 297 20Q288 29 288 43Q288 57 297 66Q306 75 320 75H403L201 276Q192 286 192 299Q192 312 201 322Q211 331 224 331Q237 331 247 322L448 120V203Q448 217 457 226Q466 235 480 235Q494 235 503 226Q512 217 512 203V43Q512 29 503 20Q494 11 480 11H320ZM80 43Q46 44 23 66Q1 89 0 123V443Q1 477 23 500Q46 522 80 523H400Q434 522 457 500Q479 477 480 443V331Q480 317 471 308Q462 299 448 299Q434 299 425 308Q416 317 416 331V443Q415 458 400 459H80Q65 458 64 443V123Q65 108 80 107H192Q206 107 215 98Q224 89 224 75Q224 61 215 52Q206 43 192 43H80Z'],
+ 'fa-chevron-down': [512, 'M233 418Q243 427 256 427Q269 427 279 418L471 226Q480 216 480 203Q480 190 471 180Q461 171 448 171Q435 171 425 180L256 350L87 180Q77 171 64 171Q51 171 41 180Q32 190 32 203Q32 216 41 226L233 418Z'],
+ 'fa-chevron-right': [320, 'M311 244Q320 254 320 267Q320 280 311 290L119 482Q109 491 96 491Q83 491 73 482Q64 472 64 459Q64 446 73 436L243 267L73 98Q64 88 64 75Q64 62 73 52Q83 43 96 43Q109 43 119 52L311 244Z'],
+ 'fa-bars': [448, 'M0 107Q0 93 9 84Q18 75 32 75H416Q430 75 439 84Q448 93 448 107Q448 121 439 130Q430 139 416 139H32Q18 139 9 130Q0 121 0 107ZM0 267Q0 253 9 244Q18 235 32 235H416Q430 235 439 244Q448 253 448 267Q448 281 439 290Q430 299 416 299H32Q18 299 9 290Q0 281 0 267ZM448 427Q448 441 439 450Q430 459 416 459H32Q18 459 9 450Q0 441 0 427Q0 413 9 404Q18 395 32 395H416Q430 395 439 404Q448 413 448 427Z'],
+ 'fa-xmark': [384, 'M343 162Q352 152 352 139Q352 126 343 116Q333 107 320 107Q307 107 297 116L192 222L87 116Q77 107 64 107Q51 107 41 116Q32 126 32 139Q32 152 41 162L147 267L41 372Q32 382 32 395Q32 408 41 418Q51 427 64 427Q77 427 87 418L192 312L297 418Q307 427 320 427Q333 427 343 418Q352 408 352 395Q352 382 343 372L237 267L343 162Z'],
+ 'fa-magnifying-glass': [512, 'M416 219Q415 289 376 342L503 468Q512 478 512 491Q512 504 503 514Q493 523 480 523Q467 523 457 514L331 387Q278 426 208 427Q120 425 61 366Q2 307 0 219Q2 131 61 72Q120 13 208 11Q296 13 355 72Q414 131 416 219ZM208 363Q247 363 280 344Q313 325 333 291Q352 257 352 219Q352 181 333 147Q313 113 280 94Q247 75 208 75Q169 75 136 94Q103 113 83 147Q64 181 64 219Q64 257 83 291Q103 325 136 344Q169 363 208 363Z'],
+ 'fa-gear': [512, 'M496 178Q500 192 490 202L446 242Q448 254 448 267Q448 280 446 292L490 332Q500 342 496 356Q489 374 480 391L475 399Q465 415 453 430Q443 441 429 437L373 419Q353 435 329 444L317 502Q313 516 298 519Q278 523 256 523Q234 523 213 519Q199 516 195 502L183 444Q159 435 139 419L83 437Q69 441 59 430Q46 415 37 399L32 391Q23 374 16 357Q12 342 22 332L66 293Q64 280 64 267Q64 254 66 242L22 202Q12 192 16 178Q23 160 32 143L37 135Q46 119 59 104Q69 93 83 97L139 115Q159 99 183 89L195 32Q199 18 214 15Q234 11 256 11Q278 11 299 15Q313 18 317 32L329 89Q353 99 373 115L429 97Q443 93 453 104Q466 119 476 135L480 143Q489 160 496 178ZM256 347Q301 346 325 307Q347 267 325 227Q301 188 256 187Q211 188 187 227Q165 267 187 307Q211 346 256 347Z'],
+ 'fa-circle-half-stroke': [512, 'M448 267Q446 185 392 131Q338 77 256 75V459Q338 457 392 403Q446 349 448 267ZM0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267Z'],
+ 'fa-moon': [384, 'M224 43Q161 44 111 74Q60 103 30 154Q1 204 0 267Q1 330 31 380Q60 431 111 460Q161 490 224 491Q317 489 379 428Q387 419 382 409Q377 399 365 400Q351 403 335 403Q261 401 211 351Q162 302 160 227Q160 177 184 137Q209 97 249 74Q259 68 257 56Q254 46 242 44Q233 43 223 43Z'],
+ 'fa-sun': [512, 'M362 12Q369 16 371 24L391 132L499 152Q507 154 511 161Q514 169 509 177L447 267L509 357Q514 365 511 373Q507 380 499 382L391 402L371 510Q369 518 362 522Q354 525 346 520L256 458L166 520Q158 525 151 522Q143 518 141 510L121 402L13 382Q5 380 1 373Q-2 365 3 357L65 267L3 177Q-2 169 1 162Q5 154 13 152L121 132L141 24Q143 16 151 12Q158 9 166 14L256 76L346 14Q354 9 362 12ZM160 267Q160 241 173 219Q186 197 208 184Q231 171 256 171Q281 171 304 184Q326 197 339 219Q352 241 352 267Q352 293 339 315Q326 337 304 350Q281 363 256 363Q231 363 208 350Q186 337 173 315Q160 293 160 267ZM384 267Q384 232 367 203Q350 174 320 156Q290 139 256 139Q222 139 192 156Q162 174 145 203Q128 232 128 267Q128 302 145 331Q162 360 192 378Q222 395 256 395Q290 395 320 378Q350 360 367 331Q384 302 384 267Z'],
+ 'fa-file-pdf': [512, 'M0 75Q1 48 19 30Q37 12 64 11H224V139Q224 153 233 162Q242 171 256 171H384V315H176Q149 316 131 334Q113 352 112 379V523H64Q37 522 19 504Q1 486 0 459V75ZM384 139H256H384H256V11L384 139ZM176 363H208H176H208Q232 364 248 379Q263 395 264 419Q263 443 248 459Q232 474 208 475H192V507Q191 522 176 523Q161 522 160 507V459V379Q161 364 176 363ZM208 443Q230 441 232 419Q230 397 208 395H192V443H208ZM304 363H336H304H336Q356 364 370 377Q383 391 384 411V475Q383 495 370 509Q356 522 336 523H304Q289 522 288 507V379Q289 364 304 363ZM336 491Q351 490 352 475V411Q351 396 336 395H320V491H336ZM416 379Q417 364 432 363H480Q495 364 496 379Q495 394 480 395H448V427H480Q495 428 496 443Q495 458 480 459H448V507Q447 522 432 523Q417 522 416 507V443V379Z'],
+ 'fa-arrow-down': [384, 'M169 482Q179 491 192 491Q205 491 215 482L375 322Q384 312 384 299Q384 286 375 276Q365 267 352 267Q339 267 329 276L224 382V75Q224 61 215 52Q206 43 192 43Q178 43 169 52Q160 61 160 75V382L55 276Q45 267 32 267Q19 267 9 276Q0 286 0 299Q0 312 9 322L169 482Z'],
+ 'fa-arrow-right': [448, 'M439 290Q448 280 448 267Q448 254 439 244L279 84Q269 75 256 75Q243 75 233 84Q224 94 224 107Q224 120 233 130L339 235H32Q18 235 9 244Q0 253 0 267Q0 281 9 290Q18 299 32 299H339L233 404Q224 414 224 427Q224 440 233 450Q243 459 256 459Q269 459 279 450L439 290Z'],
+ 'fa-clock': [512, 'M256 11Q326 12 384 45Q442 79 478 139Q512 200 512 267Q512 334 478 395Q442 455 384 489Q326 522 256 523Q186 522 128 489Q70 455 34 395Q0 334 0 267Q0 200 34 139Q70 79 128 45Q186 12 256 11ZM232 131V267V131V267Q232 280 243 287L339 351Q358 362 372 344Q383 325 365 311L280 254V131Q278 109 256 107Q234 109 232 131Z'],
+ 'fa-envelope': [512, 'M48 75Q28 76 14 89Q1 103 0 123Q1 147 19 161L237 325Q256 337 275 325L493 161Q511 147 512 123Q511 103 498 89Q484 76 464 75H48ZM0 187V395V187V395Q1 422 19 440Q37 458 64 459H448Q475 458 493 440Q511 422 512 395V187L294 350Q277 363 256 363Q235 363 218 350L0 187Z'],
+ 'fa-star': [576, 'M317 29Q308 12 288 11Q269 12 259 29L195 161L51 183Q32 186 26 204Q20 223 34 237L138 340L113 486Q111 505 126 517Q142 528 160 519L288 451L417 519Q434 528 450 517Q466 505 463 486L439 340L543 237Q556 223 551 204Q544 186 525 183L381 161L317 29Z'],
+ 'fa-heart': [512, 'M48 311 228 480 48 311 228 480Q240 491 256 491Q272 491 284 480L464 311Q511 267 512 202V196Q511 142 478 104Q445 65 393 55Q358 50 325 60Q293 70 268 95L256 107L244 95Q219 70 187 60Q154 50 119 55Q67 65 34 104Q1 142 0 196V202Q1 267 48 311Z'],
+ 'fa-play': [384, 'M73 50Q49 36 25 49Q1 63 0 91V443Q1 471 25 485Q49 498 73 484L361 308Q383 294 384 267Q383 241 361 226L73 50Z'],
+ 'fa-circle-info': [512, 'M256 523Q326 522 384 489Q442 455 478 395Q512 334 512 267Q512 200 478 139Q442 79 384 45Q326 12 256 11Q186 12 128 45Q70 79 34 139Q0 200 0 267Q0 334 34 395Q70 455 128 489Q186 522 256 523ZM216 347H240H216H240V283H216Q194 281 192 259Q194 237 216 235H264Q286 237 288 259V347H296Q318 349 320 371Q318 393 296 395H216Q194 393 192 371Q194 349 216 347ZM256 139Q270 139 279 148Q288 157 288 171Q288 185 279 194Q270 203 256 203Q242 203 233 194Q224 185 224 171Q224 157 233 148Q242 139 256 139Z'],
+ 'fa-triangle-exclamation': [512, 'M256 43Q279 44 291 63L507 431Q517 451 507 471Q495 490 472 491H40Q17 490 5 471Q-5 451 5 431L222 63Q234 44 256 43ZM256 171Q234 173 232 195V307Q234 329 256 331Q278 329 280 307V195Q278 173 256 171ZM288 395Q288 381 279 372Q270 363 256 363Q242 363 233 372Q224 381 224 395Q224 409 233 418Q242 427 256 427Q270 427 279 418Q288 409 288 395Z'],
+ 'fa-check': [448, 'M439 116Q448 126 448 139Q448 152 439 162L183 418Q173 427 160 427Q147 427 137 418L9 290Q0 280 0 267Q0 254 9 244Q19 235 32 235Q45 235 55 244L160 350L393 116Q403 107 416 107Q429 107 439 116Z'],
+ 'fa-globe': [512, 'M352 267Q352 300 349 331H163Q160 300 160 267Q160 234 163 203H349Q352 234 352 267ZM381 203H504H381H504Q512 234 512 267Q512 300 504 331H381Q384 300 384 267Q384 234 381 203ZM493 171H377H493H377Q361 73 321 19Q381 36 426 75Q470 115 493 171ZM344 171H168H344H168Q177 116 195 76Q211 41 228 25Q245 10 256 11Q267 10 284 25Q301 41 317 76Q335 116 344 171ZM135 171H19H135H19Q42 115 86 75Q131 36 191 19Q151 73 135 171ZM8 203H131H8H131Q128 234 128 267Q128 300 131 331H8Q0 300 0 267Q0 234 8 203ZM195 458Q177 418 168 363H344Q335 418 317 458Q301 493 284 509Q267 524 256 523Q245 524 228 509Q211 493 195 458ZM135 363Q151 461 191 515Q131 498 86 459Q42 419 19 363H135ZM493 363Q470 419 426 459Q381 498 322 515Q361 461 377 363H493Z'],
+ 'fa-code': [640, 'M393 12Q380 9 368 15Q357 21 353 34L225 482Q222 495 228 507Q234 518 247 522Q260 525 272 519Q283 513 287 500L415 52Q418 39 412 27Q406 16 393 12ZM473 132Q464 142 464 155Q464 168 473 178L563 267L473 356Q464 366 464 379Q464 392 473 402Q483 411 496 411Q509 411 519 402L631 290Q640 280 640 267Q640 254 631 244L519 132Q509 123 496 123Q483 123 473 132ZM167 132Q157 123 144 123Q131 123 121 132L9 244Q0 254 0 267Q0 280 9 290L121 402Q131 411 144 411Q157 411 167 402Q176 392 176 379Q176 366 167 356L77 267L167 178Q176 168 176 155Q176 142 167 132Z'],
+ 'fa-robot': [640, 'M320 11Q334 11 343 20Q352 29 352 43V107H472Q503 108 523 128Q543 148 544 179V451Q543 482 523 502Q503 522 472 523H168Q137 522 117 502Q97 482 96 451V179Q97 148 117 128Q137 108 168 107H288V43Q288 29 297 20Q306 11 320 11ZM208 395Q193 396 192 411Q193 426 208 427H240Q255 426 256 411Q255 396 240 395H208ZM304 395Q289 396 288 411Q289 426 304 427H336Q351 426 352 411Q351 396 336 395H304ZM400 395Q385 396 384 411Q385 426 400 427H432Q447 426 448 411Q447 396 432 395H400ZM264 267Q263 244 244 232Q224 222 204 232Q185 244 184 267Q185 290 204 302Q224 312 244 302Q263 290 264 267ZM416 307Q439 306 451 287Q461 267 451 247Q439 228 416 227Q393 228 381 247Q371 267 381 287Q393 306 416 307ZM48 235H64H48H64V427H48Q28 426 14 413Q1 399 0 379V283Q1 263 14 249Q28 236 48 235ZM592 235Q612 236 626 249Q639 263 640 283V379Q639 399 626 413Q612 426 592 427H576V235H592Z'],
+ 'fa-photo-film': [640, 'M256 11H576H256H576Q603 12 621 30Q639 48 640 75V299Q639 326 621 344Q603 362 576 363H256Q229 362 211 344Q193 326 192 299V75Q193 48 211 30Q229 12 256 11ZM476 118Q469 107 456 107Q443 107 436 118L380 202L363 180Q355 171 344 171Q333 171 325 180L261 260Q252 272 258 285Q265 298 280 299H360H552Q566 299 573 286Q579 274 572 262L476 118ZM336 107Q336 93 327 84Q318 75 304 75Q290 75 281 84Q272 93 272 107Q272 121 281 130Q290 139 304 139Q318 139 327 130Q336 121 336 107ZM64 139H160H64H160V395V427Q160 441 169 450Q178 459 192 459H320Q334 459 343 450Q352 441 352 427V395H512V459Q511 486 493 504Q475 522 448 523H64Q37 522 19 504Q1 486 0 459V203Q1 176 19 158Q37 140 64 139ZM72 203Q57 204 56 219V235Q57 250 72 251H88Q103 250 104 235V219Q103 204 88 203H72ZM72 307Q57 308 56 323V339Q57 354 72 355H88Q103 354 104 339V323Q103 308 88 307H72ZM72 411Q57 412 56 427V443Q57 458 72 459H88Q103 458 104 443V427Q103 412 88 411H72ZM408 427V443V427V443Q409 458 424 459H440Q455 458 456 443V427Q455 412 440 411H424Q409 412 408 427Z'],
+ 'fa-tower-cell': [576, 'M63 13Q50 9 38 14Q26 19 21 31Q0 81 0 139Q0 197 21 247Q26 259 38 264Q50 269 63 265Q75 259 80 248Q85 236 80 223Q64 184 64 139Q64 94 80 55Q85 42 80 31Q75 19 63 13ZM513 13Q501 19 496 31Q491 42 496 55Q512 94 512 139Q512 184 496 223Q491 236 496 247Q501 259 513 265Q526 270 538 265Q550 259 555 247Q576 197 576 139Q576 81 555 31Q550 19 538 14Q526 9 513 13ZM340 176Q352 160 352 139Q351 112 333 94Q315 76 288 75Q261 76 243 94Q225 112 224 139Q224 160 236 176L99 478Q94 490 98 502Q103 514 115 520Q127 526 139 521Q151 517 157 504L178 459H398L419 504Q425 516 437 521Q449 525 461 520Q474 514 478 502Q483 490 477 478L340 176ZM369 395H207H369H207L221 363H355L369 395ZM288 216 326 299 288 216 326 299H250L288 216ZM163 85Q170 64 151 53Q130 46 119 65Q104 100 104 139Q104 178 119 213Q130 232 151 225Q170 214 163 193Q152 168 152 139Q152 110 163 85ZM457 65Q446 46 425 53Q406 64 413 85Q424 110 424 139Q424 168 413 193Q406 214 425 225Q446 232 457 213Q472 178 472 139Q472 100 457 65Z'],
+ 'fa-diagram-project': [576, 'M0 91Q1 71 14 57Q28 44 48 43H144Q164 44 178 57Q191 71 192 91V107H384V91Q385 71 398 57Q412 44 432 43H528Q548 44 562 57Q575 71 576 91V187Q575 207 562 221Q548 234 528 235H432Q412 234 398 221Q385 207 384 187V171H192V187Q192 190 192 192L272 299H368Q388 300 402 313Q415 327 416 347V443Q415 463 402 477Q388 490 368 491H272Q252 490 238 477Q225 463 224 443V347Q224 344 224 342L144 235H48Q28 234 14 221Q1 207 0 187V91Z'],
+ 'fa-toolbox': [512, 'M176 99V139V99V139H336V99Q335 92 328 91H184Q177 92 176 99ZM128 139V99V139V99Q129 75 144 59Q160 44 184 43H328Q352 44 368 59Q383 75 384 99V139H412Q432 139 446 153L498 205Q512 219 512 239V315H384V299Q384 285 375 276Q366 267 352 267Q338 267 329 276Q320 285 320 299V315H192V299Q192 285 183 276Q174 267 160 267Q146 267 137 276Q128 285 128 299V315H0V239Q0 219 14 205L66 153Q80 139 100 139H128ZM0 427V347V427V347H128V363Q128 377 137 386Q146 395 160 395Q174 395 183 386Q192 377 192 363V347H320V363Q320 377 329 386Q338 395 352 395Q366 395 375 386Q384 377 384 363V347H512V427Q511 454 493 472Q475 490 448 491H64Q37 490 19 472Q1 454 0 427Z'],
+ 'fa-comment-dots': [512, 'M256 459Q328 458 385 431Q443 403 477 356Q511 309 512 251Q511 193 477 146Q443 99 385 71Q328 44 256 43Q184 44 127 71Q69 99 35 146Q1 193 0 251Q1 320 48 372Q43 409 26 435Q18 449 11 456Q8 460 6 462Q6 463 5 463Q5 463 5 464Q-2 471 1 481Q6 491 16 491Q60 489 98 472Q133 456 152 441Q200 459 256 459ZM128 219Q142 219 151 228Q160 237 160 251Q160 265 151 274Q142 283 128 283Q114 283 105 274Q96 265 96 251Q96 237 105 228Q114 219 128 219ZM256 219Q270 219 279 228Q288 237 288 251Q288 265 279 274Q270 283 256 283Q242 283 233 274Q224 265 224 251Q224 237 233 228Q242 219 256 219ZM352 251Q352 237 361 228Q370 219 384 219Q398 219 407 228Q416 237 416 251Q416 265 407 274Q398 283 384 283Q370 283 361 274Q352 265 352 251Z'],
+ 'fa-money-bill-wave': [576, 'M0 124V433V124V433Q1 463 27 475Q92 497 158 489Q223 481 288 463Q348 446 408 437Q468 429 527 444Q545 448 560 438Q575 429 576 410V101Q575 71 549 59Q484 37 419 45Q353 53 288 71Q228 88 168 96Q108 105 49 90Q30 86 16 95Q1 105 0 124ZM288 363Q254 362 231 335Q209 308 208 267Q209 226 231 199Q254 172 288 171Q322 172 345 199Q367 226 368 267Q367 308 345 335Q322 362 288 363ZM64 363Q91 364 109 382Q127 400 128 427H64V363ZM128 155Q127 182 109 200Q91 218 64 219V155H128ZM512 315V379V315V379H448Q449 352 467 334Q485 316 512 315ZM448 107H512H448H512V171Q485 170 467 152Q449 134 448 107Z'],
+ 'fa-crown': [576, 'M309 117Q327 105 328 83Q328 66 316 55Q305 43 288 43Q271 43 260 55Q248 66 248 83Q249 106 267 117L210 232Q202 245 188 249Q174 252 161 242L72 171Q80 161 80 147Q80 130 68 119Q57 107 40 107Q23 107 12 119Q0 130 0 147Q0 164 12 175Q23 187 40 187Q40 187 40 187Q41 187 41 187L86 438Q91 462 108 476Q126 491 149 491H427Q450 491 468 476Q485 462 490 438L535 187Q535 187 536 187Q536 187 536 187Q553 187 564 175Q576 164 576 147Q576 130 564 119Q553 107 536 107Q519 107 508 119Q496 130 496 147Q496 161 504 171L415 242Q402 252 388 249Q374 245 366 232L309 117Z'],
+ 'fa-server': [512, 'M64 43Q37 44 19 62Q1 80 0 107V171Q1 198 19 216Q37 234 64 235H448Q475 234 493 216Q511 198 512 171V107Q511 80 493 62Q475 44 448 43H64ZM344 115Q366 117 368 139Q366 161 344 163Q322 161 320 139Q322 117 344 115ZM392 139Q394 117 416 115Q438 117 440 139Q438 161 416 163Q394 161 392 139ZM64 299Q37 300 19 318Q1 336 0 363V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V363Q511 336 493 318Q475 300 448 299H64ZM344 371Q366 373 368 395Q366 417 344 419Q322 417 320 395Q322 373 344 371ZM400 395Q402 373 424 371Q446 373 448 395Q446 417 424 419Q402 417 400 395Z'],
+ 'fa-newspaper': [512, 'M96 107Q97 80 115 62Q133 44 160 43H448Q475 44 493 62Q511 80 512 107V427Q511 454 493 472Q475 490 448 491H80Q46 490 23 468Q1 445 0 411V139Q0 125 9 116Q18 107 32 107Q46 107 55 116Q64 125 64 139V411Q65 426 80 427Q95 426 96 411V107ZM160 131V211V131V211Q162 233 184 235H296Q318 233 320 211V131Q318 109 296 107H184Q162 109 160 131ZM368 123Q369 138 384 139H432Q447 138 448 123Q447 108 432 107H384Q369 108 368 123ZM368 219Q369 234 384 235H432Q447 234 448 219Q447 204 432 203H384Q369 204 368 219ZM160 315Q161 330 176 331H432Q447 330 448 315Q447 300 432 299H176Q161 300 160 315ZM160 411Q161 426 176 427H432Q447 426 448 411Q447 396 432 395H176Q161 396 160 411Z'],
+ 'fa-tag': [448, 'M0 91V241V91V241Q0 267 19 286L195 462Q214 481 240 481Q265 481 285 462L419 328Q437 309 437 283Q437 258 419 238L243 62Q224 43 197 43H48Q28 44 14 57Q1 71 0 91ZM112 123Q126 123 135 132Q144 141 144 155Q144 169 135 178Q126 187 112 187Q98 187 89 178Q80 169 80 155Q80 141 89 132Q98 123 112 123Z'],
+ 'fa-ticket': [576, 'M64 75Q37 76 19 94Q1 112 0 139V203Q2 217 16 222Q46 233 48 267Q46 301 16 312Q2 317 0 331V395Q1 422 19 440Q37 458 64 459H512Q539 458 557 440Q575 422 576 395V331Q574 317 560 312Q530 301 528 267Q530 233 560 222Q574 217 576 203V139Q575 112 557 94Q539 76 512 75H64ZM128 187V347V187V347Q129 362 144 363H432Q447 362 448 347V187Q447 172 432 171H144Q129 172 128 187ZM96 171Q96 157 105 148Q114 139 128 139H448Q462 139 471 148Q480 157 480 171V363Q480 377 471 386Q462 395 448 395H128Q114 395 105 386Q96 377 96 363V171Z'],
+ 'fa-chart-line': [512, 'M64 75Q64 61 55 52Q46 43 32 43Q18 43 9 52Q0 61 0 75V411Q1 445 23 468Q46 490 80 491H480Q494 491 503 482Q512 473 512 459Q512 445 503 436Q494 427 480 427H80Q65 426 64 411V75ZM471 162Q480 152 480 139Q480 126 471 116Q461 107 448 107Q435 107 425 116L320 222L263 164Q253 155 240 155Q227 155 217 164L105 276Q96 286 96 299Q96 312 105 322Q115 331 128 331Q141 331 151 322L240 232L297 290Q307 299 320 299Q333 299 343 290L471 162Z'],
+ 'fa-pen-nib': [512, 'M368 29 313 85 368 29 313 85 438 210 494 155Q510 137 510 115Q510 93 494 75L448 29Q430 13 408 13Q386 13 368 29ZM288 106 279 108 288 106 279 108 135 152Q104 162 92 193L4 457Q-2 475 11 489L165 336Q160 326 160 315Q161 295 174 281Q188 268 208 267Q228 268 242 281Q255 295 256 315Q255 335 242 349Q228 362 208 363Q197 363 187 358L34 512Q48 525 66 519L330 431Q361 419 371 388L415 244L417 235L288 106Z'],
+ 'fa-key': [512, 'M336 363Q411 361 460 311Q510 262 512 187Q510 112 460 63Q411 13 336 11Q261 13 212 63Q162 112 160 187Q160 215 168 241L7 402Q0 409 0 419V499Q2 521 24 523H104Q126 521 128 499V459H168Q190 457 192 435V395H232Q242 395 249 388L282 355Q308 363 336 363ZM376 107Q399 108 411 127Q421 147 411 167Q399 186 376 187Q353 186 341 167Q331 147 341 127Q353 108 376 107Z'],
+ 'fa-plug': [384, 'M96 11Q82 11 73 20Q64 29 64 43V139H128V43Q128 29 119 20Q110 11 96 11ZM288 11Q274 11 265 20Q256 29 256 43V139H320V43Q320 29 311 20Q302 11 288 11ZM32 171Q18 171 9 180Q0 189 0 203Q0 217 9 226Q18 235 32 235V267Q33 326 69 369Q104 412 160 424V491Q160 505 169 514Q178 523 192 523Q206 523 215 514Q224 505 224 491V424Q280 412 315 369Q351 326 352 267V235Q366 235 375 226Q384 217 384 203Q384 189 375 180Q366 171 352 171H32Z'],
+ 'fa-bolt': [448, 'M349 56Q358 33 339 17Q318 4 299 19L43 243Q27 258 34 278Q43 298 64 299H176L99 478Q90 501 109 517Q130 530 149 515L405 291Q421 276 414 256Q406 236 384 235H273L349 56Z'],
+ 'fa-satellite-dish': [512, 'M192 43Q192 29 201 20Q210 11 224 11Q304 12 369 50Q434 89 473 154Q511 219 512 299Q512 313 503 322Q494 331 480 331Q466 331 457 322Q448 313 448 299Q447 236 417 186Q388 135 337 106Q287 76 224 75Q210 75 201 66Q192 57 192 43ZM61 232 165 336 61 232 165 336 193 307Q192 303 192 299Q192 285 201 276Q210 267 224 267Q238 267 247 276Q256 285 256 299Q256 313 247 322Q238 331 224 331Q220 331 216 330L187 358L291 462Q302 474 300 488Q298 502 284 509Q249 523 208 523Q120 521 61 462Q2 403 0 315Q0 274 14 239Q21 225 35 223Q49 221 61 232ZM224 107Q306 109 360 163Q414 217 416 299Q416 313 407 322Q398 331 384 331Q370 331 361 322Q352 313 352 299Q351 245 315 208Q278 172 224 171Q210 171 201 162Q192 153 192 139Q192 125 201 116Q210 107 224 107Z'],
+ 'fa-brain': [512, 'M184 11Q208 12 224 27Q239 43 240 67V467Q239 491 224 507Q208 522 184 523Q162 523 146 509Q131 495 128 473Q120 475 112 475Q85 474 67 456Q49 438 48 411Q48 400 52 390Q29 381 14 361Q0 341 0 315Q0 291 13 272Q25 253 46 243Q32 226 32 203Q32 180 46 163Q60 146 82 140Q80 132 80 123Q80 100 94 83Q107 67 128 61Q131 39 146 25Q162 11 184 11ZM328 11Q350 11 366 25Q381 39 384 61Q405 67 418 83Q432 100 432 123Q432 132 430 140Q452 146 466 163Q480 180 480 203Q480 226 466 243Q487 253 499 272Q512 291 512 315Q512 341 498 361Q483 381 460 390Q464 400 464 411Q463 438 445 456Q427 474 400 475Q392 475 384 473Q381 495 366 509Q350 523 328 523Q304 522 288 507Q273 491 272 467V67Q273 43 288 27Q304 12 328 11Z'],
+ 'fa-microchip': [512, 'M176 35Q174 13 152 11Q130 13 128 35V75Q101 76 83 94Q65 112 64 139H24Q2 141 0 163Q2 185 24 187H64V243H24Q2 245 0 267Q2 289 24 291H64V347H24Q2 349 0 371Q2 393 24 395H64Q65 422 83 440Q101 458 128 459V499Q130 521 152 523Q174 521 176 499V459H232V499Q234 521 256 523Q278 521 280 499V459H336V499Q338 521 360 523Q382 521 384 499V459Q411 458 429 440Q447 422 448 395H488Q510 393 512 371Q510 349 488 347H448V291H488Q510 289 512 267Q510 245 488 243H448V187H488Q510 185 512 163Q510 141 488 139H448Q447 112 429 94Q411 76 384 75V35Q382 13 360 11Q338 13 336 35V75H280V35Q278 13 256 11Q234 13 232 35V75H176V35ZM160 139H352H160H352Q366 139 375 148Q384 157 384 171V363Q384 377 375 386Q366 395 352 395H160Q146 395 137 386Q128 377 128 363V171Q128 157 137 148Q146 139 160 139ZM352 171H160H352H160V363H352V171Z'],
+ 'fa-circle-nodes': [512, 'M418 169Q445 162 462 141Q479 120 480 91Q479 57 457 34Q434 12 400 11Q367 12 344 34Q322 55 320 88L136 162Q114 140 80 139Q46 140 23 162Q1 185 0 219Q1 253 23 276Q46 298 80 299Q98 299 114 291L260 419Q256 430 256 443Q257 477 279 500Q302 522 336 523Q370 522 393 500Q415 477 416 443Q414 400 381 377L418 169ZM156 243Q160 233 160 222L344 148Q349 153 355 158L318 365Q309 367 302 371L156 243Z'],
+ 'fa-download': [512, 'M288 43Q288 29 279 20Q270 11 256 11Q242 11 233 20Q224 29 224 43V286L151 212Q141 203 128 203Q115 203 105 212Q96 222 96 235Q96 248 105 258L233 386Q243 395 256 395Q269 395 279 386L407 258Q416 248 416 235Q416 222 407 212Q397 203 384 203Q371 203 361 212L288 286V43ZM64 363Q37 364 19 382Q1 400 0 427V459Q1 486 19 504Q37 522 64 523H448Q475 522 493 504Q511 486 512 459V427Q511 400 493 382Q475 364 448 363H347L301 408Q281 427 256 427Q230 427 211 408L166 363H64ZM432 419Q454 421 456 443Q454 465 432 467Q410 465 408 443Q410 421 432 419Z'],
+ 'fa-upload': [512, 'M288 120V363V120V363Q288 377 279 386Q270 395 256 395Q242 395 233 386Q224 377 224 363V120L151 194Q141 203 128 203Q115 203 105 194Q96 184 96 171Q96 158 105 148L233 20Q243 11 256 11Q269 11 279 20L407 148Q416 158 416 171Q416 184 407 194Q397 203 384 203Q371 203 361 194L288 120ZM64 363H192H64H192Q193 390 211 408Q229 426 256 427Q283 426 301 408Q319 390 320 363H448Q475 364 493 382Q511 400 512 427V459Q511 486 493 504Q475 522 448 523H64Q37 522 19 504Q1 486 0 459V427Q1 400 19 382Q37 364 64 363ZM432 467Q454 465 456 443Q454 421 432 419Q410 421 408 443Q410 465 432 467Z'],
+ 'fa-image': [512, 'M0 107Q1 80 19 62Q37 44 64 43H448Q475 44 493 62Q511 80 512 107V427Q511 454 493 472Q475 490 448 491H64Q37 490 19 472Q1 454 0 427V107ZM324 214Q316 203 304 203Q291 203 284 214L197 341L171 308Q163 299 152 299Q141 299 133 308L69 388Q61 400 66 413Q73 426 88 427H184H424Q438 427 445 414Q451 401 444 390L324 214ZM112 203Q139 202 154 179Q166 155 154 131Q139 108 112 107Q85 108 70 131Q58 155 70 179Q85 202 112 203Z'],
+ 'fa-music': [512, 'M499 17Q512 27 512 43V115V379Q511 413 484 436Q457 458 416 459Q375 458 348 436Q321 413 320 379Q321 345 348 322Q375 300 416 299Q433 299 448 304V158L192 235V443Q191 477 164 500Q137 522 96 523Q55 522 28 500Q1 477 0 443Q1 409 28 386Q55 364 96 363Q113 363 128 368V211V139Q129 116 151 108L471 12Q486 8 499 17Z'],
+ 'fa-file-lines': [384, 'M64 11Q37 12 19 30Q1 48 0 75V459Q1 486 19 504Q37 522 64 523H320Q347 522 365 504Q383 486 384 459V171H256Q242 171 233 162Q224 153 224 139V11H64ZM256 11V139V11V139H384L256 11ZM112 267H272H112H272Q287 268 288 283Q287 298 272 299H112Q97 298 96 283Q97 268 112 267ZM112 331H272H112H272Q287 332 288 347Q287 362 272 363H112Q97 362 96 347Q97 332 112 331ZM112 395H272H112H272Q287 396 288 411Q287 426 272 427H112Q97 426 96 411Q97 396 112 395Z'],
+ 'fa-font': [448, 'M254 64Q245 44 224 43Q203 44 194 64L58 427H32Q18 427 9 436Q0 445 0 459Q0 473 9 482Q18 491 32 491H128Q142 491 151 482Q160 473 160 459Q160 445 151 436Q142 427 128 427H126L144 379H304L322 427H320Q306 427 297 436Q288 445 288 459Q288 473 297 482Q306 491 320 491H416Q430 491 439 482Q448 473 448 459Q448 445 439 436Q430 427 416 427H390L254 64ZM280 315H168H280H168L224 166L280 315Z'],
+ 'fa-location-dot': [384, 'M216 510Q243 477 282 421Q321 366 352 307Q382 248 384 203Q382 121 328 67Q274 13 192 11Q110 13 56 67Q2 121 0 203Q2 248 32 307Q63 366 102 421Q141 477 168 510Q178 522 192 522Q206 522 216 510ZM192 139Q228 140 247 171Q265 203 247 235Q228 266 192 267Q156 266 137 235Q119 203 137 171Q156 140 192 139Z'],
+ 'fa-lock': [448, 'M144 155V203V155V203H304V155Q303 121 281 98Q258 76 224 75Q190 76 167 98Q145 121 144 155ZM80 203V155V203V155Q82 94 122 53Q163 13 224 11Q285 13 326 53Q366 94 368 155V203H384Q411 204 429 222Q447 240 448 267V459Q447 486 429 504Q411 522 384 523H64Q37 522 19 504Q1 486 0 459V267Q1 240 19 222Q37 204 64 203H80Z'],
+ 'fa-wave-square': [640, 'M128 75Q128 61 137 52Q146 43 160 43H320Q334 43 343 52Q352 61 352 75V427H448V267Q448 253 457 244Q466 235 480 235H608Q622 235 631 244Q640 253 640 267Q640 281 631 290Q622 299 608 299H512V459Q512 473 503 482Q494 491 480 491H320Q306 491 297 482Q288 473 288 459V107H192V267Q192 281 183 290Q174 299 160 299H32Q18 299 9 290Q0 281 0 267Q0 253 9 244Q18 235 32 235H128V75Z'],
+ 'fa-map': [576, 'M384 487 192 432 384 487 192 432V47L384 102V487ZM416 486V99V486V99L543 49Q556 44 565 51Q575 58 576 71V406Q575 421 561 428L416 486ZM15 106 160 48 15 106 160 48V435L33 486Q20 490 11 483Q1 476 0 463V128Q1 113 15 106Z'],
+ 'fa-utensils': [448, 'M416 11Q406 10 376 25Q345 39 318 77Q290 116 288 187V299Q289 326 307 344Q325 362 352 363H384V491Q384 505 393 514Q402 523 416 523Q430 523 439 514Q448 505 448 491V363V251V43Q448 29 439 20Q430 11 416 11ZM64 27Q63 14 50 11Q36 11 32 24L2 160Q0 169 0 179Q1 214 23 238Q46 263 80 267V491Q80 505 89 514Q98 523 112 523Q126 523 135 514Q144 505 144 491V267Q178 263 201 238Q223 214 224 179Q224 169 222 160L192 24Q188 11 174 11Q161 14 160 27V161Q159 170 150 171Q142 170 140 162L128 26Q126 12 112 11Q98 12 96 26L84 162Q82 170 74 171Q65 170 64 161V27ZM112 179V178V179Z'],
+ 'fa-user-shield': [640, 'M224 267Q259 267 288 250Q317 233 335 203Q352 173 352 139Q352 105 335 75Q317 45 288 28Q259 11 224 11Q189 11 160 28Q131 45 113 75Q96 105 96 139Q96 173 113 203Q131 233 160 250Q189 267 224 267ZM178 315Q103 317 52 367Q2 418 0 493Q0 506 9 514Q17 523 30 523H418Q421 523 424 523Q367 479 345 424Q322 368 320 322Q296 315 270 315H178ZM487 237 367 285 487 237 367 285Q353 291 352 307Q351 340 363 381Q374 422 403 460Q433 498 487 521Q496 525 505 521Q559 498 589 460Q618 422 629 381Q641 340 640 307Q639 291 625 285L505 237Q496 233 487 237ZM591 323Q589 363 568 404Q546 446 496 473V285L591 323Z'],
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = NAV_ICONS;
+ else root.OpenVibeNavIcons = NAV_ICONS;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1628 @@
@@ -0,0 +1,1628 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Universal Navbar
+// Consistent top bar across all services with logo, navigation,
+// notification bell, account switcher, and theme-aware styling.
+// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
+// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
+// endpoint returning { user } — asked when no usable ov_token exists, including
+// when a stored token turned out stale), logoutUrl (where Sign out goes, e.g. the
+// site's '/auth/logout?next={path}', so a server-side session ends too; {url} is
+// the full return URL, {path} its local path — loginUrl takes both too),
+// 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, logoutUrl: 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() {
+ if (document.getElementById('openvibe-navbar-styles')) return;
+ const s = document.createElement('style');
+ s.id = 'openvibe-navbar-styles';
+ s.textContent = `
+ .openvibe-navbar {
+ position: sticky; top: 0; z-index: 10000;
+ height: 52px; display: flex; align-items: center; padding: 0 16px; gap: 8px;
+ background: var(--bg-secondary, #252530);
+ border-bottom: 1px solid var(--border, #333340);
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ color: var(--text-primary, #e0e0e0);
+ }
+ .openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
+ /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
+ page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
+ .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }
+ .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }
+ .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }
+ .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }
+ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }
+ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }
+ .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
+ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }
+ .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
+ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
+ .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }
+ .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }
+ .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
+ .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
+ @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
+ .openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; height: 1em; font-size: 1em; vertical-align: -.125em; overflow: visible; }
+ .openvibe-navbar-links { min-width: 0; overflow-x: auto; scrollbar-width: none; }
+ .openvibe-navbar-links::-webkit-scrollbar { display: none; }
+ .openvibe-navbar-links a.ovnav-link { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex: none; }
+ .openvibe-navbar .ovnav-link[hidden], .openvibe-navbar .ovnav-dd[hidden], .openvibe-navbar-dropdown [hidden] { display: none !important; }
+ .openvibe-navbar .ovnav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 0 rgba(239,68,68,.6); animation: ovnavPulse 1.8s infinite; }
+ .openvibe-navbar .ovnav-dot[hidden] { display: none; }
+ @keyframes ovnavPulse { 70% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); } }
+ .openvibe-navbar .ovnav-caret { font-size: 10px; opacity: .6; }
+ .openvibe-navbar .ovnav-dd { position: relative; display: inline-flex; flex: none; }
+ .openvibe-navbar .ovnav-dd-menu { display: none; position: absolute; top: 100%; left: 0; min-width: 180px; padding: 5px; border-radius: 12px; z-index: 1002; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.45); flex-direction: column; gap: 2px; }
+ .openvibe-navbar .ovnav-dd:hover .ovnav-dd-menu, .openvibe-navbar .ovnav-dd.open .ovnav-dd-menu, .openvibe-navbar .ovnav-dd:focus-within .ovnav-dd-menu { display: flex; }
+ .openvibe-navbar .ovnav-chip { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px; border-radius: 999px; font: 700 13px/1 inherit; color: var(--chip-c, var(--text-primary, #e6edf7)); background: color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 38%, transparent); font-variant-numeric: tabular-nums; }
+ .ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }
+ .openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }
+ .openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }
+ .openvibe-navbar-dropdown .ovnav-chip--menu { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font: 700 12px/1.2 inherit; color: var(--chip-c, #fbbf24); background: color-mix(in srgb, var(--chip-c, #fbbf24) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, #fbbf24) 34%, transparent); }
+ .openvibe-navbar .ovnav-burger { all: unset; box-sizing: border-box; cursor: pointer; display: none; width: 38px; height: 38px; border-radius: 11px; flex-direction: column; align-items: center; justify-content: center; gap: 4px; flex: none; }
+ .openvibe-navbar .ovnav-burger span { display: block; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transition: transform .2s, opacity .2s; }
+ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
+ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
+ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
+ .openvibe-navbar .ovnav-burger:hover, .openvibe-navbar .ovnav-burger[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .ovnav-drawer { display: none; position: fixed; top: calc(var(--ovnav-h, 56px) + 6px); right: 8px; width: min(300px, calc(100vw - 16px)); padding: 7px; border-radius: 16px; z-index: 1001; flex-direction: column; gap: 2px;
+ background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 22px 48px rgba(0,0,0,.45);
+ max-height: calc(100vh - var(--ovnav-h, 56px) - 16px); max-height: calc(100dvh - var(--ovnav-h, 56px) - 16px - env(safe-area-inset-bottom, 0px)); overflow-y: auto; overscroll-behavior: contain; }
+ .ovnav-drawer.open { display: flex; animation: openvibe-slide-down .2s ease; }
+ .ovnav-drawer a { display: flex; align-items: center; gap: 10px; padding: 11px 12px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14.5px; font-weight: 600; }
+ .ovnav-drawer a.ovnav-sublink { padding-left: 34px; font-weight: 500; color: var(--text-secondary, #a8b3c4); }
+ .ovnav-drawer a:hover, .ovnav-drawer a.active { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .ovnav-drawer a[hidden] { display: none; }
+ .ovnav-drawer .ud-label { padding: 10px 12px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }
+ .ovnav-drawer-net:empty { display: none; } .ovnav-drawer-net { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin-top: 4px; }
+ .ovnav-drawer-net a.ovnav-net { display: flex !important; }
+ @media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
+ @media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
+ .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); 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-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
+ .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }
+ .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }
+ .ovnav-launcher .ovl-display { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 10px 4px 2px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #7d8aa0); }
+ .ovnav-launcher .ovl-display[hidden] { display: none; }
+ .ovnav-launcher .ovl-dl { font-weight: 700; letter-spacing: .6px; text-transform: uppercase; font-size: 11px; margin-right: 2px; }
+ .ovnav-launcher .ovl-seg { display: inline-flex; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 9px; overflow: hidden; }
+ .ovnav-launcher .ovl-seg button { all: unset; cursor: pointer; padding: 5px 9px; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
+ .ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
+ .ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
+ .ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }
+ .ovnav-launcher .ovl-addr a { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }
+ .ovnav-launcher .ovl-addr a.is-here { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); }
+ .ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }
+ .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, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
+ .openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
+ /* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
+ .openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
+ .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
+ .openvibe-navbar-brand .b-dot { color: var(--accent, #3b82f6); opacity: .75; margin: 0 1px; font-weight: 800; }
+ .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; }
+ .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #60a5fa); }
+ .openvibe-navbar-brand:hover .b-dot { opacity: 1; }
+ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #60a5fa); background: var(--accent-glow, rgba(59,130,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; }
+ /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */
+ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core,
+ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld,
+ .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, #60a5fa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }
+ .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }
+ .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #60a5fa); flex: none; }
+ .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
+ .openvibe-navbar-dropdown { width: min(300px, calc(100vw - 16px)); }
+ .openvibe-navbar-dropdown-menu .ud-label { padding: 8px 10px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }
+ .openvibe-navbar-dropdown-menu .ud-val { margin-left: auto; font-weight: 600; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
+ .openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }
+ .openvibe-navbar-dropdown-header { background: linear-gradient(180deg, color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent), transparent); }
+ .openvibe-navbar-dropdown-header img { border-radius: 50%; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
+ .openvibe-navbar-dropdown-header .ud-wallet a { display: inline-flex; align-items: center; gap: 5px; margin-top: 5px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; text-decoration: none; color: #fbbf24; background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); }
+ .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 {
+ padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
+ color: var(--text-secondary, #b0b0b8); text-decoration: none;
+ transition: all .15s;
+ }
+ .openvibe-navbar-links a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-links a.active { background: var(--bg-tertiary, #2a2a38); color: var(--accent-light, #60a5fa); }
+
+ .openvibe-navbar-spacer { flex: 1; }
+
+ .openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
+
+ .openvibe-navbar-avatar {
+ width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
+ border: 2px solid var(--border, #333340); transition: border-color .2s;
+ object-fit: cover;
+ }
+ .openvibe-navbar-avatar:hover { border-color: var(--accent, #3b82f6); }
+
+ .openvibe-navbar-login {
+ padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;
+ /* --accent-strong / --on-accent-strong (builtin-themes.js deriveTokens) read at 4.5:1 or better in
+ every theme (WCAG AA); the accent itself did not (white on #3b82f6 is 3.67:1). */
+ background: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); border: none; cursor: pointer;
+ transition: filter .15s; text-decoration: none; display: inline-flex; align-items: center;
+ }
+ .openvibe-navbar-login:hover { filter: brightness(1.12); }
+
+ .openvibe-navbar-dropdown {
+ position: absolute; top: 48px; right: 8px;
+ width: 260px; background: var(--bg-card, #22222c);
+ border: 1px solid var(--border, #333340); border-radius: 10px;
+ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
+ display: none; flex-direction: column; overflow: hidden;
+ animation: openvibe-slide-down .2s ease;
+ }
+ .openvibe-navbar-dropdown.open { display: flex; }
+ /* Panels never leave the screen: dvh is the VISIBLE height on phones (100vh reaches behind the address
+ bar), the panel scrolls inside itself, and it is never wider than the viewport. */
+ .openvibe-navbar-dropdown, .ovnav-launcher {
+ max-width: calc(100vw - 16px);
+ max-height: calc(100vh - 64px); max-height: calc(100dvh - 64px - env(safe-area-inset-bottom, 0px));
+ overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
+ }
+ @keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
+
+ .openvibe-navbar-dropdown-header {
+ padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);
+ display: flex; align-items: center; gap: 10px;
+ }
+ .openvibe-navbar-dropdown-header img { width: 36px; height: 36px; border-radius: 50%; }
+ .openvibe-navbar-dropdown-header .info { line-height: 1.3; }
+ .openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; }
+ .openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); }
+ .openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #60a5fa); }
+
+ .openvibe-navbar-dropdown-accounts {
+ padding: 6px 8px; border-bottom: 1px solid var(--border, #333340);
+ max-height: 140px; overflow-y: auto;
+ }
+ .openvibe-navbar-dropdown-accounts .account-item {
+ display: flex; align-items: center; gap: 8px; padding: 6px 8px;
+ border-radius: 6px; cursor: pointer; font-size: 12px;
+ color: var(--text-secondary, #b0b0b8); transition: background .12s;
+ }
+ .openvibe-navbar-dropdown-accounts .account-item:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }
+ .openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; }
+ .openvibe-navbar-dropdown-accounts .add-account {
+ display: flex; align-items: center; gap: 8px; padding: 6px 8px;
+ border-radius: 6px; cursor: pointer; font-size: 12px;
+ color: var(--text-muted, #707080); transition: background .12s;
+ text-decoration: none;
+ }
+ .openvibe-navbar-dropdown-accounts .add-account:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+
+ .openvibe-navbar-dropdown-menu { padding: 6px 8px; }
+ .openvibe-navbar-dropdown-menu a, .openvibe-navbar-dropdown-menu button {
+ display: flex; align-items: center; gap: 8px; width: 100%;
+ padding: 8px; border-radius: 6px; font-size: 12px; font-weight: 500;
+ background: none; border: none; color: var(--text-primary, #e0e0e0);
+ cursor: pointer; text-align: left; text-decoration: none;
+ transition: background .12s;
+ }
+ .openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-navbar-dropdown-menu .danger { color: var(--live-red, #e74c3c); }
+ .openvibe-navbar-dropdown-menu .icon { width: 18px; text-align: center; font-size: 14px; }
+
+ .openvibe-navbar .openvibe-network-badge {
+ font-size: 10px; padding: 2px 8px; border-radius: 4px;
+ background: rgba(59,130,246,0.1); color: var(--accent-light, #60a5fa);
+ font-weight: 500; cursor: pointer; border: 1px solid transparent;
+ transition: all .15s;
+ }
+ .openvibe-navbar .openvibe-network-badge:hover { border-color: var(--accent-dark, #2563eb); }
+
+ @media (max-width: 600px) {
+ .openvibe-navbar-links { display: none; }
+ .openvibe-navbar .openvibe-network-badge { display: none; }
+ }
+
+ /* Touch and keyboard feedback. Phones flash a square, theme-blind blue box over anything
+ tapped (-webkit-tap-highlight-color); the Frame turns it off and shows a slight press
+ instead, and keyboard focus gets one ring in the theme accent. :where() keeps it at zero
+ specificity, so the Frame's own :focus-visible rules and a host page's rules still win.
+ \`scale\` is its own property, so it composes with any transform already in use. */
+ .openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer { -webkit-tap-highlight-color: transparent; }
+ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: .96; }
+ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
+ outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: none; opacity: .82; }
+ }
+ `;
+ document.head.appendChild(s);
+ }
+
+ // ── Icons ────────────────────────────────────────────────
+ // The navbar draws its own icons. It used to borrow the page's Font Awesome, so on pages that do not load
+ // that font every menu row had an empty slot. Known names get an inline SVG; an unknown name still falls
+ // back to the page's icon font, so sites that pass their own Font Awesome icons keep working.
+ // BEGIN generated by scripts/build-navbar-icons.js from nav-icons.js; do not edit by hand
+ // Font Awesome Free 6 solid glyphs (CC BY 4.0, fontawesome.com/license/free) as filled paths,
+ // viewBox 0 0 <w> 512: only the ones the navbar draws itself. The rest of the set is nav-icons.js.
+ const NAV_ICONS = {
+ 'fa-bell': [448, 'M224 11Q210 11 201 20Q192 29 192 43V62Q136 74 101 117Q65 160 64 219V238Q63 310 16 365L8 374Q-5 389 3 408Q12 426 32 427H416Q436 426 445 408Q453 389 440 374L433 365Q385 311 384 238V219Q383 160 347 117Q312 74 256 62V43Q256 29 247 20Q238 11 224 11ZM269 504Q288 485 288 459H224H160Q160 485 179 504Q198 523 224 523Q250 523 269 504Z'],
+ 'fa-clock-rotate-left': [512, 'M75 86 41 52 75 86 41 52Q29 41 15 47Q1 52 0 69V179Q2 201 24 203H134Q151 202 156 188Q162 174 151 162L120 131Q174 77 256 75Q338 77 392 131Q446 185 448 267Q446 349 392 403Q338 457 256 459Q194 458 146 425Q135 417 122 419Q110 422 102 433Q94 444 96 456Q99 469 110 477Q173 522 256 523Q328 522 385 488Q443 454 477 396Q511 339 512 267Q511 195 477 138Q443 80 385 46Q328 12 256 11Q203 11 156 31Q110 51 75 86ZM256 139Q234 141 232 163V267Q232 277 239 284L311 356Q328 370 345 356Q359 339 345 322L280 257V163Q278 141 256 139Z'],
+ 'fa-coins': [512, 'M512 91Q511 118 474 139Q428 164 351 170Q346 167 340 165Q279 140 192 139Q180 139 168 140L166 139Q129 118 128 91Q130 57 184 34Q238 12 320 11Q402 12 456 34Q510 57 512 91ZM161 172Q176 171 192 171Q239 171 278 180Q318 188 345 202Q383 223 384 251Q384 257 382 263Q374 283 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q346 299 346 299Q346 299 346 299Q319 314 279 322Q240 331 192 331Q100 330 44 302Q41 300 38 299Q1 278 0 251Q1 224 36 204Q71 184 128 176Q144 173 161 172ZM416 251Q414 218 392 198Q435 191 468 177Q493 167 512 152V187Q511 217 468 238Q446 249 416 256Q416 256 416 256Q416 254 416 251ZM384 347Q383 374 346 395Q344 396 342 397Q341 397 340 398Q284 426 192 427Q144 427 105 418Q65 410 38 395Q1 374 0 347V312Q19 327 44 337Q105 362 192 363Q279 362 340 337Q352 332 363 326Q372 321 380 315Q381 314 383 313Q383 312 384 312V315V321V347ZM416 347V315V347V315V289Q445 283 468 273Q493 263 512 248V283Q512 299 497 314Q471 339 416 352Q416 350 416 347ZM192 459Q279 458 340 433Q365 423 384 408V443Q382 477 328 500Q274 522 192 523Q110 522 56 500Q2 477 0 443V408Q19 423 44 433Q105 458 192 459Z'],
+ 'fa-link': [640, 'M580 279Q622 234 622 176Q622 119 580 74Q541 37 489 32Q438 28 394 59L392 60Q381 68 379 81Q377 93 385 105Q393 115 405 117Q418 120 429 112L431 111Q456 94 484 96Q513 98 535 119Q558 144 558 176Q558 209 535 233L422 346Q397 369 365 369Q333 369 308 346Q287 324 285 295Q283 267 300 242L301 240Q308 229 306 217Q304 204 293 196Q282 188 270 190Q257 192 249 203L248 205Q217 249 221 301Q225 352 263 391Q308 433 365 433Q423 433 468 391L580 279ZM60 255Q18 300 18 358Q18 415 60 460Q99 497 151 502Q202 506 247 475L248 474Q259 466 261 453Q263 441 256 429Q247 419 235 417Q222 414 211 422L209 423Q184 440 156 438Q127 436 105 415Q82 390 82 358Q82 325 106 301L218 188Q243 165 275 165Q307 165 332 188Q353 210 355 239Q357 267 340 292L339 294Q332 305 334 318Q336 330 347 338Q358 346 370 344Q383 342 391 331L392 329Q423 285 419 233Q415 182 377 143Q332 101 275 101Q217 101 173 143L60 255Z'],
+ 'fa-palette': [512, 'M512 267Q512 268 512 268Q512 269 512 270Q511 297 490 314Q470 331 442 331H344Q324 332 310 345Q297 359 296 379Q296 384 297 389Q300 403 307 417Q307 418 308 419Q318 439 320 461Q320 485 305 503Q291 521 267 523Q261 523 256 523Q184 522 127 488Q69 454 35 396Q1 339 0 267Q1 195 35 138Q69 80 127 46Q184 12 256 11Q328 12 385 46Q443 80 477 138Q511 195 512 267ZM128 299Q128 285 119 276Q110 267 96 267Q82 267 73 276Q64 285 64 299Q64 313 73 322Q82 331 96 331Q110 331 119 322Q128 313 128 299ZM128 203Q142 203 151 194Q160 185 160 171Q160 157 151 148Q142 139 128 139Q114 139 105 148Q96 157 96 171Q96 185 105 194Q114 203 128 203ZM288 107Q288 93 279 84Q270 75 256 75Q242 75 233 84Q224 93 224 107Q224 121 233 130Q242 139 256 139Q270 139 279 130Q288 121 288 107ZM384 203Q398 203 407 194Q416 185 416 171Q416 157 407 148Q398 139 384 139Q370 139 361 148Q352 157 352 171Q352 185 361 194Q370 203 384 203Z'],
+ 'fa-plus': [448, 'M256 91Q256 77 247 68Q238 59 224 59Q210 59 201 68Q192 77 192 91V235H48Q34 235 25 244Q16 253 16 267Q16 281 25 290Q34 299 48 299H192V443Q192 457 201 466Q210 475 224 475Q238 475 247 466Q256 457 256 443V299H400Q414 299 423 290Q432 281 432 267Q432 253 423 244Q414 235 400 235H256V91Z'],
+ 'fa-right-from-bracket': [512, 'M378 117 501 240 378 117 501 240Q512 251 512 267Q512 283 501 294L378 417Q368 427 354 427Q340 427 330 417Q320 407 320 393V331H192Q178 331 169 322Q160 313 160 299V235Q160 221 169 212Q178 203 192 203H320V141Q320 127 330 117Q340 107 354 107Q368 107 378 117ZM160 107H96H160H96Q82 107 73 116Q64 125 64 139V395Q64 409 73 418Q82 427 96 427H160Q174 427 183 436Q192 445 192 459Q192 473 183 482Q174 491 160 491H96Q55 490 28 463Q1 436 0 395V139Q1 98 28 71Q55 44 96 43H160Q174 43 183 52Q192 61 192 75Q192 89 183 98Q174 107 160 107Z'],
+ 'fa-shield-halved': [512, 'M256 11Q263 11 269 14L458 94Q475 101 485 116Q496 131 496 151Q497 202 479 271Q461 340 414 405Q368 471 282 514Q256 526 230 514Q144 471 98 405Q51 340 33 271Q15 202 16 151Q16 131 27 116Q37 101 54 94L243 14Q249 11 256 11ZM256 78V456V78V456Q324 421 363 367Q401 313 417 256Q432 198 432 152L256 78Z'],
+ 'fa-text-height': [576, 'M64 139V107V139V107H128V427H96Q82 427 73 436Q64 445 64 459Q64 473 73 482Q82 491 96 491H224Q238 491 247 482Q256 473 256 459Q256 445 247 436Q238 427 224 427H192V107H256V139Q256 153 265 162Q274 171 288 171Q302 171 311 162Q320 153 320 139V91Q319 71 306 57Q292 44 272 43H160H48Q28 44 14 57Q1 71 0 91V139Q0 153 9 162Q18 171 32 171Q46 171 55 162Q64 153 64 139ZM503 52Q493 43 480 43Q467 43 457 52L393 116Q379 132 386 151Q395 170 416 171H448V363H416Q395 364 386 383Q379 402 393 418L457 482Q467 491 480 491Q493 491 503 482L567 418Q581 402 574 383Q565 364 544 363H512V171H544Q565 170 574 151Q581 132 567 116L503 52Z'],
+ 'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],
+ 'fa-user': [448, 'M224 267Q259 267 288 250Q317 233 335 203Q352 173 352 139Q352 105 335 75Q317 45 288 28Q259 11 224 11Q189 11 160 28Q131 45 113 75Q96 105 96 139Q96 173 113 203Q131 233 160 250Q189 267 224 267ZM178 315Q103 317 52 367Q2 418 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q446 418 396 367Q345 317 270 315H178Z'],
+ 'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],
+ 'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],
+ };
+ // Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives).
+ const NAV_ICONS_LAZY = {
+ 320: 'fa-chevron-right',
+ 384: 'fa-xmark fa-moon fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',
+ 448: 'fa-book fa-hand-fist fa-bars fa-arrow-right fa-check fa-tag fa-bolt fa-font fa-lock fa-utensils',
+ 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-chevron-down fa-magnifying-glass fa-gear fa-circle-half-stroke fa-sun fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-brain fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',
+ 576: 'fa-house fa-id-card fa-video fa-star fa-tower-cell fa-diagram-project fa-money-bill-wave fa-crown fa-ticket fa-map',
+ 640: 'fa-comments fa-gamepad fa-network-wired fa-people-arrows fa-people-group fa-shirt fa-code fa-robot fa-photo-film fa-wave-square fa-user-shield',
+ };
+ const NAV_ICONS_REV = 'bb866b6e2cf3'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
+ // END generated
+ // Icons are solid filled paths generated from the Font Awesome Free solid font, so every OpenVibe
+ // page gets the same crisp glyphs with no dependency on Font Awesome CSS. The glyphs the navbar
+ // draws itself ship above; the rest of the set (nav-icons.js, ~16 KB brotli) is fetched once,
+ // after first paint, the first time a site's link, chip or menu row names one. Until it lands
+ // that icon is an empty <svg> of the glyph's exact size, then its path is filled in. A page that
+ // loads nav-icons.js itself (before navbar.js) has every glyph from the first frame. Names not in
+ // the set, and every lazy glyph if nav-icons.js cannot load, fall back to an <i class="fa-solid">
+ // for pages that do load Font Awesome.
+ const _selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
+ let _lazyWidth = null;
+ let _iconState = 0; // 0 idle, 1 loading, 2 loaded, 3 unavailable
+ function lazyWidth(name) {
+ if (!_lazyWidth) { _lazyWidth = {}; for (const w in NAV_ICONS_LAZY) NAV_ICONS_LAZY[w].split(' ').forEach(n => { _lazyWidth[n] = Number(w); }); }
+ return _lazyWidth[name] || 0;
+ }
+ function adoptIcons() {
+ const set = root.OpenVibeNavIcons;
+ if (!set || typeof set !== 'object') return false;
+ Object.assign(NAV_ICONS, set); _iconState = 2;
+ return true;
+ }
+ /**
+ * A shared browser file next to this navbar.js: the same origin and the same release (a site that
+ * serves its own pinned copy at /shared/ gets its own), else the network's copy.
+ */
+ function sibling(name) {
+ const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
+ return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
+ }
+ /** nav-icons.js next to this navbar.js (same origin, same release), else the network's copy. */
+ function iconsUrl() {
+ if (_config.iconsUrl) return _config.iconsUrl;
+ return `${sibling('nav-icons.js')}?v=${NAV_ICONS_REV}`;
+ }
+ function iconSlots() {
+ const out = new Set();
+ [_navEl, typeof document !== 'undefined' ? document : null].forEach((r) => {
+ try { if (r && r.querySelectorAll) r.querySelectorAll('svg[data-ovnav-ic]').forEach(el => out.add(el)); } catch { /* */ }
+ });
+ return out;
+ }
+ function fillIcons() {
+ iconSlots().forEach((el) => {
+ const g = NAV_ICONS[el.getAttribute('data-ovnav-ic')]; if (!g) return;
+ el.removeAttribute('data-ovnav-ic'); el.removeAttribute('data-ovnav-cls');
+ el.innerHTML = `<path fill="currentColor" d="${g[1]}"/>`;
+ });
+ }
+ function iconsUnavailable() {
+ _iconState = 3;
+ iconSlots().forEach((el) => { try { el.outerHTML = `<i class="fa-solid ${escapeAttr(el.getAttribute('data-ovnav-cls'))}"></i>`; } catch { /* */ } });
+ }
+ function loadIcons() {
+ if (_iconState) return; _iconState = 1;
+ let started = false;
+ const start = () => {
+ if (started) return; started = true;
+ if (adoptIcons()) return fillIcons();
+ try {
+ const sc = document.createElement('script'); sc.id = 'ov-nav-icons-loader'; sc.src = iconsUrl(); sc.async = true;
+ try { sc.fetchPriority = 'low'; } catch { /* */ }
+ sc.onload = () => { if (adoptIcons()) fillIcons(); else iconsUnavailable(); };
+ sc.onerror = iconsUnavailable;
+ document.head.appendChild(sc);
+ } catch { iconsUnavailable(); }
+ };
+ // After first paint: the frame that shows the navbar goes out before the request does.
+ try { requestAnimationFrame(() => setTimeout(start, 0)); } catch { /* */ }
+ setTimeout(start, 300); // background tabs get no animation frames
+ }
+ function navIcon(cls) {
+ if (!cls) return '';
+ const key = String(cls).split(/\s+/).find(c => NAV_ICONS[c] || (_iconState !== 3 && lazyWidth(c)));
+ if (!key) return `<i class="fa-solid ${escapeAttr(cls)}"></i>`;
+ const g = NAV_ICONS[key] || (adoptIcons() ? NAV_ICONS[key] : null);
+ const w = g ? g[0] : lazyWidth(key);
+ const svg = `<svg class="ovnav-ic" viewBox="0 0 ${w} 512" style="width:${(w / 512).toFixed(3)}em" aria-hidden="true" focusable="false"`;
+ if (g) return `${svg}><path fill="currentColor" d="${g[1]}"/></svg>`;
+ loadIcons();
+ return `${svg} data-ovnav-ic="${key}" data-ovnav-cls="${escapeAttr(cls)}"></svg>`;
+ }
+ function upgradeIconsWhenFontsReady() { /* icons no longer depend on webfonts */ }
+
+ // Release watch (ADR-016): a few seconds after the page settles, load release-watch.js from the
+ // same place as this navbar, so every site that serves /release.json keeps open tabs current with
+ // no page changes. Opt out with init({ releaseWatch: false }); an object is its OVReleaseConfig
+ // ({ metricsUrl, url, inPlace }).
+ function loadReleaseWatch() {
+ try {
+ const rw = _config.releaseWatch;
+ if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;
+ if (rw && typeof rw === 'object' && !root.OVReleaseConfig) root.OVReleaseConfig = rw;
+ const sc = document.createElement('script'); sc.src = sibling('release-watch.js'); sc.async = true;
+ try { sc.fetchPriority = 'low'; } catch { /* */ }
+ document.head.appendChild(sc);
+ } catch { /* optional */ }
+ }
+
+ // ─── 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) : ''; }
+ // Descriptive hosts ('youtube-downloader') are for search engines; the brand shows the name people use.
+ const LONG_SUBS = { 'youtube-downloader': 'YT', youtubedownloader: 'YT', 'youtube-download': 'YT', youtube: 'YT', ytdl: 'YT' };
+ function subLabel(sub) { return SUB_LABELS[sub] || SUB_LABELS[LONG_SUBS[sub] && LONG_SUBS[sub].toLowerCase()] || LONG_SUBS[sub] || titleCase(String(sub).replace(/-/g, ' ')).replace(/ /g, ''); }
+
+ /**
+ * { 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>'}`;
+ }
+
+
+ // ── Frame data (https://openvibe.network/api/frame) ─────────────────────────────
+ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
+ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
+ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
+ const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {
+ const KEY = 'ov_frame_v1', TTL = 30 * 60000;
+ let inflight = null;
+ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
+ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
+ function clean(d) {
+ if (!d || !Array.isArray(d.nav)) return null;
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const f = d.footer || {}, lg = f.legal || {};
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
+ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
+ }
+ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
+ function refresh() {
+ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
+ inflight = fetch('https://openvibe.network/api/frame?host=' + encodeURIComponent(host()), { credentials: 'omit' })
+ .then(r => (r.ok ? r.json() : null)).then(clean)
+ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
+ .catch(() => null);
+ return inflight;
+ }
+ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
+ function get(onFirst) {
+ const c = cached();
+ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
+ return c ? c.data : null;
+ }
+ return { get, refresh };
+ })());
+
+ // ── Network launcher ─────────────────────────────────────
+ const LAUNCHER_SITES = [
+ { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
+ { 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.network/api/catalog.json'; // allowed by every site's CSP
+ const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';
+ 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_catalog2') || '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), go: t.hosts && t.hosts.short ? okUrl('https://' + t.hosts.short + '/') : null,
+ hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter(h => typeof h === 'string' && h), id: String(t.id || ''), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
+ try { sessionStorage.setItem('ov_catalog2', 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.go || 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) {
+ // Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),
+ // the last row hands the query to the Tools search page, so typing never does nothing.
+ const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);
+ const siteList = (OVFrame.get() && OVFrame.get().nav.length ? OVFrame.get().nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES).filter(hit);
+ const famList = fams.filter(hit);
+ const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];
+ const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;
+ body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-fams">${siteList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
+ + (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
+ + `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;
+ return;
+ }
+ const frame = OVFrame.get();
+ const sites = frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
+ const soon = frame ? frame.soon : [];
+ // On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people
+ // can pick the one they will remember.
+ const cur = currentHost().toLowerCase();
+ const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;
+ const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';
+ body.innerHTML = addresses + `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
+ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
+ ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
+ }
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ 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><div class="ovl-display" hidden></div>';
+ bindDisplayControls(panel.querySelector('.ovl-display'));
+ nav.appendChild(panel);
+ regPanel(panel, 'launcher', close);
+ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('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'); location.href = first ? first.href : TOOLS_SEARCH + encodeURIComponent(input.value.trim()); } 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 { /* */ }
+ });
+ }
+
+ /** One anonymous page-view count per page load (host only), which is what orders the network's
+ * navigation. Skipped when the browser asks not to be tracked. */
+ let _counted = false;
+ function countView() {
+ if (_counted || _config.countViews === false) return; _counted = true;
+ try {
+ if (navigator.globalPrivacyControl || navigator.doNotTrack === '1') return;
+ if (!/^https:$/.test(location.protocol)) return;
+ const url = 'https://openvibe.network/api/frame/hit';
+ if (navigator.sendBeacon) navigator.sendBeacon(url);
+ } catch { /* */ }
+ }
+
+ /** Status chips beside the bell (balances, counters): [{ id, icon, value, valueId, title, tone, onClick, hidden }]. */
+ function chipsHTML(chips, cls) {
+ return (Array.isArray(chips) ? chips : []).filter(Boolean).map(c => `<button type="button" class="${cls || 'ovnav-chip'}" data-chip-id="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}${c.title ? ` title="${escapeAttr(c.title)}"` : ''}${c.hidden ? ' hidden' : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-v"${c.valueId ? ` id="${escapeAttr((cls ? 'menu-' : '') + c.valueId)}"` : ''}>${escapeAttr(c.value == null ? '' : c.value)}</span></button>`).join('');
+ }
+ function bindChips(scope) {
+ (_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {
+ if (!c || typeof c.onClick !== 'function') return;
+ scope.querySelectorAll(`[data-chip-id="${c.id}"]`).forEach(el => { if (!el.__b) { el.__b = true; el.addEventListener('click', (e) => { e.stopPropagation(); c.onClick(e); }); } });
+ });
+ }
+
+ /** Link behaviour: per-link onClick, in-app navigation (onNavigate), dropdowns, the mobile drawer. */
+ function bindLinks(nav, links) {
+ const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l]));
+ const drawer = nav.querySelector('#openvibe-drawer'), burger = nav.querySelector('#openvibe-burger');
+ const closeDrawer = () => { if (drawer) drawer.classList.remove('open'); if (burger) burger.setAttribute('aria-expanded', 'false'); };
+ nav.addEventListener('click', (e) => {
+ const a = e.target.closest && e.target.closest('a.ovnav-link, .ovnav-net'); if (!a || !nav.contains(a)) return;
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ const item = byId.get(a.getAttribute('data-link-id'));
+ // A parent of a dropdown opens it on touch (there is no hover); a second tap follows the link.
+ const dd = a.parentElement && a.parentElement.classList.contains('ovnav-dd') ? a.parentElement : null;
+ if (dd && matchMedia('(hover: none)').matches && !dd.classList.contains('open')) { e.preventDefault(); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); dd.classList.add('open'); return; }
+ if (item && typeof item.onClick === 'function') { const r = item.onClick(e); if (r === false) e.preventDefault(); closeDrawer(); if (e.defaultPrevented) return; }
+ if (typeof _config.onNavigate === 'function' && a.target !== '_blank') {
+ let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }
+ if (same && _config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault();
+ }
+ closeDrawer();
+ });
+ document.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('.ovnav-dd')) nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); });
+ if (burger && drawer) {
+ burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); });
+ document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); });
+ regPanel(drawer, 'nav-drawer', closeDrawer);
+ }
+ }
+
+ /** Dropdown, launcher and notifications share one coordinator (panels.js): one open at a time, Escape, rotation.
+ * Panels are registered where they are created (no page-wide observers); registrations queue until the script loads. */
+ const _panelQueue = [];
+ function regPanel(el, id, close) {
+ if (!el) return;
+ const opts = { el, openClass: 'open', id, close };
+ if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
+ _panelQueue.push(opts);
+ if (document.getElementById('ov-panels-loader')) return;
+ const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = sibling('panels.js'); sc.async = true;
+ sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); };
+ document.head.appendChild(sc);
+ }
+
+ /** Sites in the user menu: most used first (Frame data), never the one we are on. */
+ function acrossHTML() {
+ const frame = OVFrame.get();
+ const here = currentHost().toLowerCase();
+ const list = (frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, url: n.url, icon: n.icon || n.id })) : LAUNCHER_SITES)
+ .filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h); } catch { return false; } }).slice(0, 5);
+ return list.map(n => `<a href="${escapeAttr(n.url)}"><span class="icon"><span class="ov-icon" data-icon="${escapeAttr(n.icon)}" data-size="20" data-fx="none"></span></span> ${escapeAttr(n.name)}</a>`).join('');
+ }
+
+ /** Display rows (text size, animations) cycle their value; state lives in theme-loader.js. */
+ function bindDisplayRows(dropdown) {
+ const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');
+ if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }
+ const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };
+ const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; }); };
+ rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));
+ root.addEventListener('ov:display', paint); paint();
+ }
+
+ /** OpenCoins balance in the menu header (one request when the menu first opens). */
+ let _walletLoaded = false;
+ async function loadWallet(dropdown) {
+ if (_walletLoaded || !_config.token || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true;
+ try {
+ const r = await fetch(`${_config.apiBase}/api/coins/me`, { headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'include' });
+ if (!r.ok) return; const j = await r.json(); const bal = Number(j.balance ?? (j.wallet && j.wallet.balance));
+ const el = dropdown.querySelector('#openvibe-wallet'); if (!el || !isFinite(bal)) return;
+ el.innerHTML = `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()}</a>`; el.hidden = false;
+ } catch { /* the chip is optional */ }
+ }
+
+ /** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
+ function networkLinksHTML(pageLinks, inDrawer) {
+ if (_config.networkLinks === false) return '';
+ const frame = OVFrame.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });
+ if (!frame) return '';
+ const here = currentHost().toLowerCase();
+ const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
+ const max = inDrawer ? 6 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : 4);
+ const pick = frame.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
+ if (!pick.length) return '';
+ return (inDrawer ? '<div class="ud-label">Across OpenVibe</div>' : `<span class="ovnav-sep" aria-hidden="true"></span>`) + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
+ }
+
+ /** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */
+ function bindDisplayControls(box) {
+ const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;
+ const paint = () => {
+ const d = L.display.get();
+ const seg = (key, val, label, title) => `<button type="button" data-k="${key}" data-v="${val}" aria-pressed="${String(d[key]) === val}" title="${title}">${label}</button>`;
+ box.innerHTML = `<span class="ovl-dl">Display</span><span class="ovl-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>
+ <span class="ovl-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span><a href="https://openvibe.network/themes">Themes</a>`;
+ box.hidden = false;
+ };
+ box.addEventListener('click', (e) => { const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint(); });
+ root.addEventListener('ov:display', paint);
+ paint();
+ }
+
+ // ── 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', realtime: _config.notificationsRealtime === true }); 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 = sibling('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
+ }
+
+ /** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
+ function sectionsHTML(sections) {
+ return (Array.isArray(sections) ? sections : []).filter(sec => sec && Array.isArray(sec.items) && sec.items.length).map(sec =>
+ `<div class="ud-sec"${sec.id ? ` data-sec-id="${escapeAttr(sec.id)}"` : ''}${sec.hidden ? ' hidden' : ''}>${sec.label ? `<div class="ud-label">${escapeAttr(sec.label)}</div>` : ''}${sec.items.map(menuItemHTML).join('')}</div><div class="sep"></div>`).join('');
+ }
+ const sectionItems = (sections) => (Array.isArray(sections) ? sections : []).reduce((o, sec) => o.concat((sec && sec.items) || []), []);
+
+ function menuItemHTML(item) {
+ if (!item) return '';
+ if (item.sep) return '<div class="sep"></div>';
+ const icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';
+ const cls = item.danger ? ' class="danger"' : '';
+ const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}`;
+ const val = item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : '';
+ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}${val}</a>`;
+ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}${val}</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(); const r = item.onClick(e); if (r === false) e.preventDefault(); if (item.keepOpen !== true) container.closest('.openvibe-navbar-dropdown')?.classList.remove('open'); });
+ }
+ }
+
+ /** One top-level link. Supports { label, href, icon, id, elId, page, hidden, external, dot, dotId, children: [...] }. */
+ function linkHTML(l, inDrawer) {
+ const attrs = `${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.elId ? ` id="${escapeAttr((inDrawer ? 'drawer-' : '') + l.elId)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}${l.hidden ? ' hidden' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}`;
+ const kids = Array.isArray(l.children) ? l.children.filter(Boolean) : [];
+ const inner = `${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(l.label)}</span>${l.dot !== undefined ? `<span class="ovnav-dot"${l.dotId && !inDrawer ? ` id="${escapeAttr(l.dotId)}"` : ''}${l.dot ? '' : ' hidden'}></span>` : ''}`;
+ const a = `<a href="${escapeAttr(l.href || '#')}" class="ovnav-link${l.active ? ' active' : ''}"${attrs}>${inner}${kids.length && !inDrawer ? '<span class="ovnav-caret" aria-hidden="true">▾</span>' : ''}</a>`;
+ if (!kids.length) return a;
+ const menu = kids.map(k => `<a href="${escapeAttr(k.href || '#')}" class="ovnav-link ovnav-sublink"${k.id ? ` data-link-id="${escapeAttr(k.id)}"` : ''}${k.hidden ? ' hidden' : ''}>${k.icon ? `<span class="icon">${navIcon(k.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(k.label)}</span></a>`).join('');
+ return inDrawer ? a + menu : `<div class="ovnav-dd"${l.hidden ? ' hidden' : ''}>${a}<div class="ovnav-dd-menu">${menu}</div></div>`;
+ }
+ const flatLinks = (list) => list.reduce((out, l) => out.concat([l], Array.isArray(l.children) ? l.children : []), []);
+
+ function currentLinks() {
+ const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
+ const path = (typeof location !== 'undefined' && location.pathname) || '/';
+ const activePage = _activePage;
+ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (activePage ? l.page === activePage : (l.href === path && path !== '/')) }));
+ }
+
+ let _activePage = null; // setActive(): single-page apps tell the navbar where they are
+ const SERVICE_LINKS = {
+ live: [
+ { label: 'Watch', href: '/' },
+ { label: 'Chat', href: '/chat' },
+ { label: 'VODs', href: '/vods' },
+ { label: 'Game', href: '/game' },
+ ],
+ tools: [
+ { label: 'Home', href: '/' },
+ ],
+ games: [
+ { label: 'Play', href: '/game' },
+ { label: 'Canvas', href: '/canvas' },
+ { label: 'Leaderboard', href: '/leaderboard' },
+ ],
+ media: [
+ { label: 'Home', href: '/' },
+ { label: 'Network', href: 'https://openvibe.network' },
+ { label: 'Live', href: 'https://openvibe.live' },
+ { label: 'Tools', href: 'https://openvibe.tools' },
+ ],
+ network: [
+ { label: 'Home', href: '/' },
+ { label: 'My Account', href: '/my' },
+ { label: 'Themes', href: '/themes' },
+ ],
+ maps: [
+ { label: 'Map', href: '/' },
+ { label: 'Camps', href: '/camps' },
+ ],
+ food: [
+ { label: 'Food Banks', href: '/' },
+ { label: 'Meal Plan', href: '/#meal-plan' },
+ ],
+ img: [
+ { label: 'Convert', href: 'https://convert.openvibe.tools' },
+ { label: 'Compress', href: 'https://compress.openvibe.tools' },
+ { label: 'Resize', href: 'https://resize.openvibe.tools' },
+ { label: 'Crop', href: 'https://crop.openvibe.tools' },
+ ],
+ yt: [
+ { label: 'Download', href: '/' },
+ ],
+ audio: [
+ { label: 'Convert', href: 'https://audio.openvibe.tools' },
+ { label: 'Trim', href: 'https://trim.openvibe.tools' },
+ { label: 'Pitch', href: 'https://pitch.openvibe.tools' },
+ { label: 'Reverb', href: 'https://reverb.openvibe.tools' },
+ ],
+ text: [
+ { label: 'Fancy', href: 'https://fancy.openvibe.tools' },
+ { label: 'Zalgo', href: 'https://zalgo.openvibe.tools' },
+ { label: 'ASCII', href: 'https://ascii.openvibe.tools' },
+ { label: 'Symbols', href: 'https://symbols.openvibe.tools' },
+ ],
+ logo: [
+ { label: 'Title', href: 'https://title.openvibe.tools' },
+ { label: 'Wordmark', href: 'https://wordmark.openvibe.tools' },
+ { label: 'Badge', href: 'https://badge.openvibe.tools' },
+ { label: 'Thumbnail', href: 'https://thumbnail.openvibe.tools' },
+ ],
+ docs: [
+ { label: 'Merge', href: 'https://mergepdf.openvibe.tools' },
+ { label: 'Split', href: 'https://splitpdf.openvibe.tools' },
+ { label: 'Compress', href: 'https://compresspdf.openvibe.tools' },
+ { label: 'Images→PDF', href: 'https://image2pdf.openvibe.tools' },
+ ],
+ net: [
+ { label: 'Lookup', href: 'https://lookup.openvibe.tools' },
+ { label: 'My IP', href: 'https://myip.openvibe.tools' },
+ { label: 'DNS', href: 'https://dns.openvibe.tools' },
+ { label: 'Ping', href: 'https://ping.openvibe.tools' },
+ { label: 'SSL', href: 'https://ssl.openvibe.tools' },
+ ],
+ dev: [
+ { label: 'JSON', href: 'https://json.openvibe.tools' },
+ { label: 'Base64', href: 'https://base64.openvibe.tools' },
+ { label: 'JWT', href: 'https://jwt.openvibe.tools' },
+ { label: 'Regex', href: 'https://regex.openvibe.tools' },
+ { label: 'Diff', href: 'https://diff.openvibe.tools' },
+ ],
+ };
+
+ function getAccounts() {
+ try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
+ }
+
+ // ─── SSO auth resolution ───────────────────────────────────
+ // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,
+ // and Domain=.openvibe.tools on the tools apex + every tool subdomain.
+ // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,
+ // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,
+ // img., yt., docs., food., …) do NOT mount /auth/* — they only read the
+ // shared cookie — so sign-in/out always goes through the gateway apex.
+ // Detection order: ov_token cookie → localStorage → page-provided token →
+ // app-specific session endpoint (opt-in via config.sessionUrl).
+ const TOOLS_APEX = 'openvibe.tools';
+ const TOKEN_KEY = 'ov_token';
+
+ function currentHost() {
+ return (typeof location !== 'undefined' && location.hostname) || '';
+ }
+
+ function onToolsDomain(host) {
+ const h = host !== undefined ? host : currentHost();
+ return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX);
+ }
+
+ function onNetworkDomain(host) {
+ const h = host !== undefined ? host : currentHost();
+ try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; }
+ }
+
+ function parseCookieToken(cookieStr) {
+ const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/);
+ if (!m || !m[1]) return null;
+ try { return decodeURIComponent(m[1]); } catch { return m[1]; }
+ }
+
+ function getCookieToken() {
+ if (typeof document === 'undefined') return null;
+ return parseCookieToken(document.cookie);
+ }
+
+ function getStoredToken() {
+ try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; }
+ }
+
+ function resolveToken() {
+ return getCookieToken() || getStoredToken() || _config.token || null;
+ }
+
+ /** Where "Sign In" goes on this host. */
+ function resolveLoginHref(host, returnUrl) {
+ if (_config.loginUrl) return fillUrl(_config.loginUrl, returnUrl);
+ if (onToolsDomain(host)) {
+ // Every *.openvibe.tools host signs in through the gateway apex: it
+ // holds the OAuth state cookie + redirect_uri (both apex-host-only)
+ // and sets ov_token with Domain=.openvibe.tools, so the session
+ // reaches all satellites. A host-local /auth/login would 404 on
+ // satellites and break the OAuth state check on gateway subdomains.
+ return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;
+ }
+ if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
+ return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`;
+ }
+
+ function setAuthCookie(token) {
+ if (typeof document === 'undefined') return;
+ const maxAge = 24 * 60 * 60; // matches the 24h access JWT
+ if (onToolsDomain()) {
+ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`;
+ } else {
+ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
+ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
+ }
+ }
+
+ function clearAuthState() {
+ if (typeof document !== 'undefined') {
+ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
+ if (onToolsDomain()) {
+ document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`;
+ }
+ }
+ try {
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem('openvibe_anon_token');
+ localStorage.removeItem('openvibe_active_account');
+ } catch { /* storage unavailable */ }
+ }
+
+ function persistToken(token) {
+ try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ }
+ setAuthCookie(token);
+ }
+
+ /** GET {apiBase}/api/auth/me with the Bearer token. */
+ async function fetchMe(token) {
+ try {
+ const res = await fetch(`${_config.apiBase}/api/auth/me`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (res.ok) {
+ const data = await res.json();
+ return { ok: true, user: (data && (data.user || data)) || null };
+ }
+ return { ok: false, unauthorized: res.status === 401 || res.status === 403 };
+ } catch {
+ return { ok: false, unauthorized: false };
+ }
+ }
+
+ /** loginUrl/logoutUrl templates: {url} = the full return URL, {path} = its path + query (for sites that only accept local next=). */
+ function fillUrl(template, returnUrl) {
+ const full = returnUrl || (typeof location !== 'undefined' ? location.href : '/');
+ let path = '/';
+ try { const u = new URL(full, typeof location !== 'undefined' ? location.href : 'https://openvibe.network/'); path = u.pathname + u.search; } catch { /* */ }
+ return String(template).replace('{url}', encodeURIComponent(full)).replace('{path}', encodeURIComponent(path));
+ }
+
+ /** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */
+ async function gatewayRefresh() {
+ try {
+ const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' });
+ if (!res.ok) return null;
+ const data = await res.json();
+ return data && data.token ? data : null;
+ } catch { return null; }
+ }
+
+ /** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */
+ async function networkRefresh(token) {
+ try {
+ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) return null;
+ const data = await res.json();
+ return data && data.token ? data : null;
+ } catch { return null; }
+ }
+
+ /**
+ * Resolve the signed-in user from the shared SSO state.
+ * With a token: validate it against the Network. If it is rejected, make
+ * ONE refresh attempt (same-origin gateway refresh first on tools hosts,
+ * then the Network's grace refresh) before giving up — never leave a
+ * stale "Sign In" when a recoverable session exists.
+ */
+ async function resolveSessionUser() {
+ const token = resolveToken();
+ if (token) {
+ const me = await fetchMe(token);
+ if (me.ok && me.user) return { user: me.user, token };
+ if (me.unauthorized) {
+ if (onToolsDomain()) {
+ const r = await gatewayRefresh();
+ if (r) {
+ const user = r.user || (await fetchMe(r.token)).user;
+ if (user) { persistToken(r.token); return { user, token: r.token }; }
+ }
+ }
+ const n = await networkRefresh(token);
+ if (n) {
+ const user = n.user || (await fetchMe(n.token)).user;
+ if (user) { persistToken(n.token); return { user, token: n.token }; }
+ }
+ // A stored token nobody accepts any more: drop it, so it cannot keep hiding a
+ // good session (the site's own below, or the next sign-in).
+ try { if (localStorage.getItem('ov_token') === token) localStorage.removeItem('ov_token'); } catch { /* */ }
+ }
+ if (!_config.sessionUrl) return null;
+ }
+ // No usable token — ask the page's own session endpoint if it has one (a site with a
+ // server-side session, such as openvibe.blog, knows who is signed in even when this
+ // browser's stored token is stale or was never shared with the page).
+ if (_config.sessionUrl) {
+ try {
+ const res = await fetch(_config.sessionUrl, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ const user = (data && (data.user || data)) || null;
+ if (user && (user.username || user.id)) return { user, token: null };
+ }
+ } catch { /* signed out */ }
+ }
+ return null;
+ }
+
+ 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 = sibling('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 = sibling('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()
+ .then((session) => {
+ _authInFlight = null;
+ if (session && session.user) {
+ _config.user = session.user;
+ if (session.token) _config.token = session.token;
+ render();
+ recordHistory();
+ enableHandoff();
+ } else {
+ maybeSilentLogin();
+ }
+ try {
+ document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
+ detail: { user: session ? session.user : null, token: session ? session.token : null },
+ }));
+ } catch { /* non-DOM environment */ }
+ return session ? session.user : null;
+ })
+ .catch(() => { _authInFlight = null; return null; });
+ return _authInFlight;
+ }
+
+ function escapeAttr(value) {
+ return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');
+ }
+
+ function getAvatarInitial(user) {
+ const source = user?.display_name || user?.username || 'O';
+ return String(source).trim().charAt(0).toUpperCase() || 'O';
+ }
+
+ function makeAvatarPlaceholder(user, size = 64) {
+ const initial = getAvatarInitial(user);
+ const bg = user?.profile_color || '#3b82f6';
+ const svg = `
+ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
+ <rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/>
+ <text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="${Math.round(size * 0.42)}" font-weight="700" fill="#ffffff">${initial}</text>
+ </svg>`;
+ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;
+ }
+
+ function avatarSrc(user, size = 64) {
+ return user?.avatar_url || makeAvatarPlaceholder(user, size);
+ }
+
+ function avatarImg(user, size = 64, className = 'openvibe-navbar-avatar', id = '') {
+ const fallback = makeAvatarPlaceholder(user, size);
+ const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
+ const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');
+ return `<img class="${escapeAttr(className)}" src="${escapeAttr(avatarSrc(user, size))}" data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;
+ }
+
+ function attachAvatarFallbacks(rootEl) {
+ rootEl?.querySelectorAll('img[data-fallback-src]').forEach((img) => {
+ img.addEventListener('error', () => {
+ const fallback = img.dataset.fallbackSrc;
+ if (fallback && img.src !== fallback) {
+ img.src = fallback;
+ }
+ }, { once: true });
+ });
+ }
+
+ /**
+ * "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 = sibling('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() {
+ // A site may put state classes on the bar (Live's transparent hero mode). A re-render must not lose them.
+ let carried = []; try { carried = Array.from((_navEl && _navEl.classList) || []).filter(c => c !== 'openvibe-navbar'); } catch { carried = []; }
+ if (_navEl) _navEl.remove();
+
+ const nav = document.createElement('nav');
+ nav.className = 'openvibe-navbar';
+ carried.forEach(c => { try { nav.classList.add(c); } catch { /* */ } });
+ const svc = _config.service;
+
+ 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();
+ const isAnon = u && u.is_anon;
+ 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 = sibling('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
+ }
+ nav.innerHTML = `
+ ${brandHTML(brand)}
+ <div class="openvibe-navbar-links">
+ ${links.map(l => linkHTML(l, false)).join('')}
+ ${networkLinksHTML(links)}
+ ${u && u.role === 'admin' && _config.adminLink !== false && !flatLinks(links).some(l => l.id === 'admin') ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} Admin</a>` : ''}
+ </div>
+ <div class="openvibe-navbar-spacer"></div>
+ <div class="openvibe-navbar-right">
+ ${u ? chipsHTML(_config.chips) : ''}
+ <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>`}
+ ${links.length ? '<button type="button" class="ovnav-burger" id="openvibe-burger" aria-label="Menu" aria-haspopup="true" aria-expanded="false"><span></span><span></span><span></span></button>' : ''}
+ </div>
+ ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${links.map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div></div>` : ''}
+ `;
+ if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
+ bindLinks(nav, links);
+ bindChips(nav);
+ bindLauncher(nav);
+ countView();
+ // Pages that wire the bell themselves run right after init(); give them the first go.
+ setTimeout(() => mountBell(nav, u), 0);
+
+ // Dropdown
+ if (u) {
+ const dropdown = document.createElement('div');
+ dropdown.className = 'openvibe-navbar-dropdown';
+ dropdown.id = 'openvibe-user-dropdown';
+
+ const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
+ const menuCfg = _config.menu || {};
+ 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">
+ ${avatarImg(u, 72, '', '')}
+ <div class="info">
+ <div class="name">${escapeAttr(u.display_name || u.username)}</div>
+ <div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>
+ ${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div>'}
+ ${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
+ </div>
+ </div>
+ <div class="openvibe-navbar-dropdown-accounts"${_config.accounts === false ? ' hidden' : ''}>
+ ${otherAccounts.map(a => `
+ <div class="account-item" data-account-id="${a.id}">
+ ${avatarImg(a, 48, '', '')}
+ <span>${a.display_name || a.username}${a.is_anon ? ' (anon)' : ''}</span>
+ </div>
+ `).join('')}
+ <div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">
+ <span style="width:24px;text-align:center">${navIcon('fa-user-secret')}</span>
+ <span>Switch to Anonymous</span>
+ </div>
+ <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">
+ <span style="width:24px;text-align:center">${navIcon('fa-plus')}</span>
+ <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.length ? `<div class="ud-label">${escapeAttr((_config.menu && _config.menu.label) || brand.short || 'This site')}</div>${before.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}
+ ${sectionsHTML(menuCfg.sections)}
+ ${menuCfg.defaults === false ? '' : `<div class="ud-label">You</div>
+ <a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>
+ ${!isAnon && u.username ? `<a href="https://openvibe.live/@${escapeAttr(u.username)}"><span class="icon">${navIcon('fa-tower-broadcast')}</span> My Channel</a>` : ''}
+ <a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>
+ <div class="sep"></div>`}
+ <div class="ud-label">Display</div>
+ <button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button>
+ <button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>
+ <a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>
+ <div class="sep"></div>
+ <div class="ud-label">Across OpenVibe</div>
+ ${acrossHTML()}
+ ${menuCfg.defaults === false ? '' : `
+ <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>
+ <a href="https://openvibe.network/my#linked"><span class="icon">${navIcon('fa-link')}</span> Linked Services</a>
+ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-shield-halved')}</span> Admin Panel</a>` : ''}`}
+ ${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
+ <div class="sep"></div>
+ <button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button>
+ </div>
+ `;
+ nav.appendChild(dropdown);
+ bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));
+ bindChips(dropdown);
+ if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same) { if (_config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
+ bindDisplayRows(dropdown);
+ regPanel(dropdown, 'user-menu');
+ if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
+
+ // Avatar click toggles dropdown
+ nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
+ dropdown.classList.toggle('open');
+ if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; if (root.OpenVibeIcons) root.OpenVibeIcons.mount(dropdown); else if (!document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } }
+ });
+
+ // Close on outside click
+ document.addEventListener('click', e => {
+ if (!nav.contains(e.target)) dropdown.classList.remove('open');
+ });
+
+ // Account switching
+ dropdown.querySelectorAll('[data-account-id]').forEach(el => {
+ el.addEventListener('click', () => {
+ const id = el.dataset.accountId;
+ document.dispatchEvent(new CustomEvent('openvibe-switch-account', { detail: { accountId: id } }));
+ dropdown.classList.remove('open');
+ });
+ });
+
+ dropdown.querySelector('#openvibe-logout-btn')?.addEventListener('click', () => {
+ dropdown.classList.remove('open');
+ if (_config.onLogout) _config.onLogout();
+ else {
+ clearAuthState();
+ _config.user = null;
+ _config.token = null;
+ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
+ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
+ if (_config.logoutUrl) {
+ // The site ends its own server-side session too, then sends us back.
+ window.location.href = fillUrl(_config.logoutUrl, window.location.href);
+ } else if (onToolsDomain()) {
+ // The gateway also clears the Domain=.openvibe.tools cookie
+ // and the httpOnly refresh cookie, then sends us back here.
+ window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;
+ } else {
+ window.location.reload();
+ }
+ }
+ });
+ } else {
+ nav.querySelector('#openvibe-login-btn')?.addEventListener('click', (event) => {
+ if (!_config.onLogin) return;
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ event.preventDefault();
+ _config.onLogin();
+ });
+ }
+
+ // Insert into page — use navbar-mount placeholder if available, otherwise prepend to body
+ const mount = document.getElementById('navbar-mount');
+ if (mount) {
+ mount.appendChild(nav);
+ } else {
+ document.body.prepend(nav);
+ }
+ _navEl = nav;
+ attachAvatarFallbacks(nav);
+ return nav;
+ }
+
+ const OpenVibeNavbar = {
+ init(opts = {}) {
+ Object.assign(_config, opts);
+ injectStyles();
+ const el = render();
+ upgradeIconsWhenFontsReady();
+ if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
+ // Pages that hand us a resolved user (openvibe.network, the tools
+ // gateway hub) keep full control. Everyone else — pages that pass
+ // only a token, or nothing at all — gets the user resolved from
+ // the shared SSO state (ov_token cookie / localStorage / optional
+ // sessionUrl) and the navbar re-renders when it arrives.
+ // auth: 'external' — the site signs people in itself (Live) and tells us with setUser().
+ if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
+ else 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(); },
+
+ /** Update user (after account switch). */
+ setUser(user) {
+ _config.user = user;
+ 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 signed-in user changed (sites with auth: 'external'). Pass null for signed out. */
+ setUser(user, token) { _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; return render(); },
+ /** Single-page apps: mark the link whose `page` matches as active. */
+ setActive(page) { _activePage = page || null; if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); },
+ /** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */
+ setChip(id, value, patch) {
+ const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];
+ lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));
+ document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; });
+ },
+ /** Patch a top-level or dropdown link by id: updateLink('broadcast', { hidden: false, dot: true, label }). */
+ updateLink(id, patch) {
+ const list = _runtimeLinks || _config.links || [];
+ const l = flatLinks(list).find(x => x && x.id === id); if (!l) return; Object.assign(l, patch || {});
+ if (!_navEl) return;
+ _navEl.querySelectorAll(`[data-link-id="${id}"]`).forEach(a => {
+ if ('hidden' in patch) { a.hidden = !!patch.hidden; const dd = a.parentElement; if (dd && dd.classList.contains('ovnav-dd')) dd.hidden = !!patch.hidden; }
+ if ('label' in patch) { const t = a.querySelector('.ovnav-l'); if (t) t.textContent = patch.label; }
+ if ('href' in patch) a.setAttribute('href', patch.href);
+ if ('dot' in patch) { const d = a.querySelector('.ovnav-dot'); if (d) d.hidden = !patch.dot; }
+ });
+ },
+ /** Patch a custom menu item by id: updateMenuItem('admin', { hidden: false, value: '3' }). */
+ updateMenuItem(id, patch) {
+ const item = sectionItems(_config.menu && _config.menu.sections).find(x => x && x.id === id); if (item) Object.assign(item, patch || {});
+ document.querySelectorAll(`.openvibe-navbar-dropdown [data-menu-id="${id}"]`).forEach(el => { if ('hidden' in patch) el.hidden = !!patch.hidden; if ('value' in patch) { const v = el.querySelector('.ud-val'); if (v) v.textContent = patch.value; } });
+ },
+
+ /** 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. */
+ getBellMount() {
+ return _navEl?.querySelector('#openvibe-bell-mount') || null;
+ },
+
+ getElement() { return _navEl; },
+
+ destroy() {
+ _navEl?.remove();
+ _navEl = null;
+ },
+
+ /** Internal auth helpers — exposed for tests, not a public API. */
+ _auth: {
+ parseCookieToken,
+ onToolsDomain,
+ onNetworkDomain,
+ resolveLoginHref,
+ resolveToken,
+ resolveSessionUser,
+ clearAuthState,
+ persistToken,
+ fillUrl,
+ },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
+ else root.OpenVibeNavbar = OpenVibeNavbar;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+/*
+ * openvibe-shared/notification-live.js — the notification badge's realtime feed (roadmap WS-E task 3, WS-F task 1;
+ * ADR-005 amendment 2). notification-ui.js loads it on demand when a site turns it on (navbar notificationsRealtime).
+ * For every (re)connect it asks Network for a realtime ticket (POST <api>/api/v1/realtime/ticket: Bearer token, or the
+ * cookie on openvibe.network itself) and opens Events' stream with it, without credentials:
+ * <stream_url>?topics=network.notification.*&ticket=<ticket>[&last_event_id=<seq>]
+ * Events streams only the person's own network.notification.created events (subject visibility); one whose subject is
+ * not the ticket's is ignored all the same. Each calls onNotification(payload); `event: gap` calls onGap.
+ * A ticket opens one stream: errors close it and reconnect with a fresh ticket from the cursor, backing off 2 s to 15 min
+ * with jitter. Stops (the caller's polling is all there is) after 10 failures in a row, on a Content-Security-Policy
+ * refusal, when signed out (401/403) or when Network has realtime off (404/503). Hidden 5 min: closed; shown: resumed.
+ * OVNotificationLive.create({ ticketUrl, token, credentials, onNotification, onGap, onState }) → { start, stop, restart, state }
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OVNotificationLive) return;
+ const TYPE_RE = /^network\.notification\./;
+ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+ const MAX_FAILURES = 10; // 2 s, 4 s, … 512 s: about 9 to 17 minutes of retries before polling only
+
+ function create(o) {
+ const rt = { state: 'off', failures: 0, lastSeq: null, subject: null, origin: null, events: 0, ignored: 0, gaps: 0, tickets: 0 };
+ let es = null; let stopped = true; let gen = 0;
+ const t = {}; // timers: retry, hide
+ const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); };
+ const call = (f, ...a) => { try { if (typeof f === 'function') f(...a); } catch { /* the page's handler must not break the feed */ } };
+ function set(s) { if (rt.state !== s) { rt.state = s; call(o.onState, s); } }
+ function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }
+ function halt(s) { close(); root.clearTimeout(t.retry); t.retry = null; set(s); }
+
+ async function open() {
+ if (stopped || es || rt.state === 'ticket') return;
+ if (document.hidden) { set('hidden'); return; }
+ const g = gen;
+ set('ticket');
+ let tk;
+ try {
+ const tok = typeof o.token === 'function' ? o.token() : o.token;
+ const r = await root.fetch(o.ticketUrl, { method: 'POST', credentials: o.credentials || 'omit', cache: 'no-store', headers: tok ? { Authorization: `Bearer ${tok}` } : {} });
+ if (g !== gen) return;
+ if (r.status === 401 || r.status === 403) return halt('signed-out');
+ if (r.status === 404 || r.status === 501 || r.status === 503) return halt('unavailable');
+ if (!r.ok) throw new Error('ticket');
+ tk = await r.json();
+ if (!tk || typeof tk.ticket !== 'string' || !SUBJECT_RE.test(String(tk.subject)) || !Array.isArray(tk.topics)) throw new Error('ticket');
+ } catch {
+ if (g === gen) fail();
+ return;
+ }
+ if (g !== gen) return;
+ let u;
+ try {
+ u = new URL(tk.stream_url);
+ if (!/^https?:$/.test(u.protocol)) throw new Error('url');
+ u.searchParams.set('topics', tk.topics.join(','));
+ u.searchParams.set('ticket', tk.ticket);
+ if (rt.lastSeq != null) u.searchParams.set('last_event_id', String(rt.lastSeq));
+ } catch { fail(); return; }
+ rt.tickets++; rt.origin = u.origin;
+ rt.subject = tk.subject;
+ set('connecting');
+ let s;
+ try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; }
+ s.onopen = () => { if (es === s) { rt.failures = 0; set('open'); } };
+ s.onmessage = (e) => { if (es === s) heard(e); };
+ s.onerror = () => { if (es === s) fail(); };
+ s.addEventListener('gap', (e) => {
+ if (es !== s) return;
+ let gap = null; try { gap = JSON.parse(e.data); } catch { /* */ }
+ rt.gaps++;
+ call(o.onGap, gap);
+ });
+ }
+ function heard(e) {
+ let m; try { m = JSON.parse(e.data); } catch { return; }
+ const seq = m && typeof m.seq === 'number' ? m.seq : Number(e.lastEventId);
+ if (Number.isFinite(seq)) { if (rt.lastSeq != null && seq <= rt.lastSeq) return; rt.lastSeq = seq; }
+ const ev = m && m.event;
+ // Events already sends only this person's own; anything else is never shown.
+ if (!ev || !TYPE_RE.test(String(ev.event_type)) || !ev.subject || ev.subject.type !== 'user' || ev.subject.id !== rt.subject) { rt.ignored++; return; }
+ rt.events++;
+ call(o.onNotification, ev.payload && typeof ev.payload === 'object' ? ev.payload : {}, ev);
+ }
+ // A ticket opens one stream, so the browser's own reconnect (same URL) is never used: close, back off, new ticket.
+ function fail() {
+ close();
+ if (stopped || rt.state === 'blocked') return;
+ if (++rt.failures >= MAX_FAILURES) { set('failed'); return; }
+ set('backoff');
+ later('retry', () => { if (!stopped) { if (document.hidden) set('hidden'); else open(); } }, Math.min(9e5, 2e3 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));
+ }
+
+ // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.
+ document.addEventListener('securitypolicyviolation', (e) => {
+ if (stopped || !rt.origin || String(e.blockedURI || '').indexOf(rt.origin) !== 0) return;
+ halt('blocked');
+ });
+ document.addEventListener('visibilitychange', () => {
+ if (stopped) return;
+ if (document.hidden) { if (es) later('hide', () => { if (document.hidden && es) { close(); set('hidden'); } }, 5 * 60 * 1000); return; }
+ root.clearTimeout(t.hide); t.hide = null;
+ if (rt.state === 'hidden') open();
+ });
+ root.addEventListener('online', () => { if (!stopped && (rt.state === 'failed' || rt.state === 'backoff')) { rt.failures = 0; root.clearTimeout(t.retry); set('off'); open(); } });
+
+ const api = {
+ start() { if (!stopped) return api; stopped = false; set('off'); open(); return api; },
+ stop() { stopped = true; gen++; close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); set('off'); return api; },
+ /** Another account (or a new token): forget the cursor and the subject, start again. */
+ restart() { api.stop(); rt.lastSeq = null; rt.subject = null; rt.failures = 0; return api.start(); },
+ state: () => ({ ...rt }),
+ };
+ return api;
+ }
+
+ root.OVNotificationLive = { create };
+ if (typeof module !== 'undefined' && module.exports) module.exports = root.OVNotificationLive;
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,765 @@
@@ -0,0 +1,765 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Notification UI Client (shared across every OpenVibe site)
+//
+// One store (openvibe.network) → one bell everywhere. This file renders the bell +
+// dropdown panel, toasts, a full-page inbox, and handles Web Push subscription.
+//
+// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
+// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })
+// OpenVibeNotifications.createBell(mountEl)
+// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox
+//
+// Reads: GET /api/notifications?limit&offset&unread_only&category&q&since
+// GET /api/notifications/unread-count
+// Realtime (init({ realtime: true }), off by default): notification-live.js, loaded from beside this file, hears the
+// person's network.notification.created over OpenVibe.Events with a ticket from POST /api/v1/realtime/ticket;
+// each event or gap re-reads the count. Polling stays: every 15 s without a stream, every 2 min with one.
+// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ const POLL_INTERVAL = 15_000;
+ const REALTIME_POLL = 120_000; // with an open realtime stream the poll is only a safety net (reads made on other sites)
+ const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
+ const PAGE_SIZE = 30;
+ const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = sticky
+ const MAX_TOASTS = 3;
+ const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
+ const CATEGORY_LABELS = {
+ social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams', economy: 'Economy',
+ achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements',
+ };
+ const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network' };
+
+ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
+ let _pollTimer = null;
+ let _lastSeenAt = null; // newest created_at we have toasted / seen
+ let _unreadCount = 0;
+ let _toastContainer = null;
+ let _panelEl = null;
+ let _bellEl = null;
+ let _audioCache = {};
+ let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
+ let _channel = null; // BroadcastChannel for same-origin tab sync
+ let _live = null; // OVNotificationLive feed (realtime on)
+ let _lastPoll = 0;
+ let _soon = null; let _soonAll = false;
+ const _views = new Map(); // container → view state (panel + inboxes)
+
+ // ── Styles ──────────────────────────────────────────────
+ function injectStyles() {
+ if (document.getElementById('openvibe-notif-styles')) return;
+ const style = document.createElement('style');
+ style.id = 'openvibe-notif-styles';
+ style.textContent = `
+ @keyframes openvibe-toast-in { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+ @keyframes openvibe-toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }
+ @keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }
+ @keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }
+ @keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
+ @keyframes openvibe-spin { to { transform: rotate(360deg); } }
+
+ .openvibe-toast-container { position: fixed; top: 82px; right: 14px; z-index: 100000; display: flex; flex-direction: column; gap: 6px; pointer-events: none; max-width: 340px; width: 100%; }
+ .openvibe-toast { pointer-events: all; background: color-mix(in srgb, var(--bg-card, #22222c) 94%, transparent); border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent); border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 10px 24px rgba(0,0,0,0.28); animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1); cursor: pointer; position: relative; overflow: hidden; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); transition: border-color .2s; }
+ .openvibe-toast:hover { border-color: var(--accent, #3b82f6); }
+ .openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }
+ .openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }
+ .openvibe-toast-icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
+ .openvibe-toast-body { flex: 1; min-width: 0; }
+ .openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }
+ .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #60a5fa); font-weight: 500; }
+ .openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+ .openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }
+ .openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }
+ .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #3b82f6); }
+ .openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }
+ .openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }
+ .openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }
+ .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; }
+ .openvibe-toast-actions button.primary { background: var(--accent, #3b82f6); color: #fff; border-color: var(--accent, #3b82f6); }
+
+ .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }
+ .openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }
+ .openvibe-bell:hover svg, .openvibe-bell.open svg { fill: var(--accent-light, #60a5fa); }
+ .openvibe-bell .badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; background: var(--live-red, #e74c3c); color: #fff; border-radius: 8px; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; animation: openvibe-badge-pulse .4s ease; }
+ .openvibe-bell .badge.hidden { display: none; }
+ .openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }
+
+ .openvibe-notif-panel { position: fixed; top: 62px; right: 16px; width: 400px; max-height: calc(100vh - 86px); background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5)); z-index: 99999; display: none; flex-direction: column; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); animation: openvibe-slide-down .2s ease; overflow: hidden; }
+ .openvibe-notif-panel.open { display: flex; }
+ .openvibe-notif-inbox { display: flex; flex-direction: column; background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; overflow: hidden; color: var(--text-primary, #e0e0e0); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; min-height: 420px; max-height: 78vh; }
+
+ .openvibe-nv-head { padding: 12px 14px; border-bottom: 1px solid var(--border, #333340); display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+ .openvibe-nv-head h3 { font-size: 15px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }
+ .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 600; background: var(--live-red, #e74c3c); color: #fff; border-radius: 9px; padding: 1px 7px; }
+ .openvibe-nv-head h3 .count.zero { display: none; }
+ .openvibe-nv-actions { display: flex; gap: 4px; align-items: center; }
+ .openvibe-nv-actions button, .openvibe-nv-actions a { background: none; border: 1px solid transparent; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 5px 8px; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; line-height: 1; }
+ .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+ .openvibe-nv-actions button.on { color: var(--accent-light, #60a5fa); border-color: color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent); }
+ .openvibe-nv-actions button:disabled { opacity: .45; cursor: default; }
+ .openvibe-nv-actions svg { width: 14px; height: 14px; fill: currentColor; }
+
+ .openvibe-nv-tools { padding: 8px 12px; border-bottom: 1px solid var(--border, #333340); display: flex; flex-direction: column; gap: 8px; }
+ .openvibe-nv-search { position: relative; }
+ .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 7px 28px 7px 30px; border-radius: 8px; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-primary, #e0e0e0); font-size: 12px; outline: none; }
+ .openvibe-nv-search input:focus { border-color: var(--accent, #3b82f6); }
+ .openvibe-nv-search svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; fill: var(--text-muted, #707080); pointer-events: none; }
+ .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; display: none; }
+ .openvibe-nv-search.has-q .clear { display: block; }
+ .openvibe-nv-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
+ .openvibe-nv-chips button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 10px; border-radius: 999px; cursor: pointer; line-height: 1.2; }
+ .openvibe-nv-chips button:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e0e0e0); }
+ .openvibe-nv-chips button.active { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); border-color: var(--accent, #3b82f6); color: var(--accent-light, #60a5fa); }
+ .openvibe-nv-chips select { margin-left: auto; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 8px; border-radius: 8px; cursor: pointer; }
+
+ .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 4px 0; overscroll-behavior: contain; }
+ .openvibe-notif-item { padding: 10px 12px 10px 14px; display: flex; gap: 10px; align-items: flex-start; cursor: pointer; transition: background .15s; border-left: 3px solid transparent; position: relative; }
+ .openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); border-left-color: var(--accent, #3b82f6); }
+ .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; width: 32px; text-align: center; }
+ .openvibe-notif-item .icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
+ .openvibe-notif-item .content { flex: 1; min-width: 0; }
+ .openvibe-notif-item .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; padding-right: 18px; }
+ .openvibe-notif-item.unread .title { font-weight: 650; }
+ .openvibe-notif-item .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+ .openvibe-notif-item .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+ .openvibe-notif-item .meta .tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }
+ .openvibe-notif-item .dismiss { position: absolute; top: 8px; right: 8px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; opacity: 0; transition: opacity .15s; }
+ .openvibe-notif-item:hover .dismiss { opacity: 1; }
+ .openvibe-notif-item .dismiss:hover { color: var(--live-red, #e74c3c); background: var(--bg-tertiary, #2a2a38); }
+ .openvibe-nv-empty { text-align: center; padding: 44px 24px; color: var(--text-muted, #707080); font-size: 13px; }
+ .openvibe-nv-empty .icon { font-size: 34px; margin-bottom: 8px; display: block; opacity: .5; }
+ .openvibe-nv-more { display: flex; justify-content: center; padding: 10px; }
+ .openvibe-nv-more button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 12px; padding: 6px 14px; border-radius: 8px; cursor: pointer; }
+ .openvibe-nv-more .spinner { width: 16px; height: 16px; border: 2px solid var(--border, #333340); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: openvibe-spin .8s linear infinite; }
+ .openvibe-nv-foot { padding: 8px 12px; border-top: 1px solid var(--border, #333340); font-size: 11px; color: var(--text-muted, #707080); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
+ .openvibe-nv-foot a { color: var(--accent-light, #60a5fa); text-decoration: none; }
+ .openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }
+
+ /* The panel hangs under whatever navbar the site has (its top is measured when it opens) and never
+ leaves the visible viewport: dvh instead of vh, room for the home indicator, list scrolls inside. */
+ .openvibe-notif-panel { top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));
+ max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px)); border-radius: 16px; }
+ .openvibe-notif-item .title .count { display: inline-block; margin-left: 4px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); vertical-align: 1px; }
+ /* Phones and narrow windows: a sheet from edge to edge under the bar, down to the bottom inset. */
+ @media (max-width: 640px) {
+ .openvibe-notif-panel { left: 8px; right: 8px; width: auto; bottom: calc(8px + env(safe-area-inset-bottom, 0px)); max-height: none; }
+ .openvibe-notif-panel [data-act] > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } /* icon-only actions, labels stay for screen readers */
+ .openvibe-notif-panel [data-act] { min-width: 36px; min-height: 36px; justify-content: center; }
+ .openvibe-notif-item { padding-top: 10px; padding-bottom: 10px; }
+ }
+ @media (max-height: 520px) { .openvibe-notif-item .msg { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } }
+ @media (max-width: 460px) {
+ .openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ // ── Helpers ─────────────────────────────────────────────
+ function esc(v) {
+ if (v == null) return '';
+ return String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+ }
+ function richOf(n) {
+ const rc = n && (n.rich_content ?? n.richContent);
+ if (!rc) return {};
+ if (typeof rc === 'object') return rc;
+ try { return JSON.parse(rc) || {}; } catch { return {}; }
+ }
+ function urlOf(n) { return (n && n.url) || richOf(n).url || null; }
+ function isRead(n) { return !!(n && (n.is_read === 1 || n.is_read === true || n.read === true)); }
+ function parseDate(v) {
+ if (!v) return null;
+ // SQLite "YYYY-MM-DD HH:MM:SS" is UTC without a zone marker.
+ const s = String(v);
+ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? s.replace(' ', 'T') + 'Z' : s);
+ return isNaN(d.getTime()) ? null : d;
+ }
+ function timeAgo(v) {
+ const d = parseDate(v); if (!d) return '';
+ const diff = (Date.now() - d.getTime()) / 1000;
+ if (diff < 45) return 'just now';
+ if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
+ return d.toLocaleDateString();
+ }
+ function inboxUrl() {
+ if (_config.inboxUrl) return _config.inboxUrl;
+ try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }
+ }
+ function iconHtml(n) {
+ const rich = richOf(n);
+ const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);
+ if (img && /^https?:\/\//.test(String(img))) return `<img src="${esc(img)}" alt="" loading="lazy" onerror="this.replaceWith(document.createTextNode('${esc(n.icon || '🔔')}'))">`;
+ return esc(n.icon || '🔔');
+ }
+ function navigateTo(url) {
+ if (!url) return;
+ try {
+ const u = new URL(url, window.location.href);
+ window.location.href = u.toString();
+ } catch { window.location.href = url; }
+ }
+
+ // ── API ─────────────────────────────────────────────────
+ function isCrossOrigin() {
+ try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
+ }
+ function apiFetch(path, opts = {}) {
+ const headers = { ...(opts.headers || {}) };
+ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
+ if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
+ headers['Content-Type'] = 'application/json';
+ opts = { ...opts, body: JSON.stringify(opts.body) };
+ }
+ return fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: isCrossOrigin() ? 'omit' : 'include' });
+ }
+ async function apiJson(path, opts) {
+ const res = await apiFetch(path, opts);
+ let data = null; try { data = await res.json(); } catch { /* */ }
+ if (!res.ok) { const e = new Error((data && data.error) || `HTTP ${res.status}`); e.status = res.status; e.body = data; throw e; }
+ return data || {};
+ }
+ function hasSession() { return !!_config.token || !isCrossOrigin(); }
+
+ // ── Cross-tab sync (same origin) ────────────────────────
+ function channel() {
+ if (_channel !== null) return _channel || null;
+ try { _channel = new BroadcastChannel('openvibe-notifications'); _channel.onmessage = (e) => onSyncMessage(e.data || {}); }
+ catch { _channel = false; }
+ return _channel || null;
+ }
+ function broadcast(msg) { try { channel()?.postMessage(msg); } catch { /* */ } }
+ function onSyncMessage(msg) {
+ if (msg.type === 'unread') setBadge(msg.count, { quiet: true });
+ else if (msg.type === 'read') { for (const v of _views.values()) applyReadLocally(v, msg.ids || [], msg.all); }
+ }
+
+ // ── Sound ───────────────────────────────────────────────
+ function playSound(priority) {
+ if (!_preferences.sound) return;
+ const file = SOUNDS[priority]; if (!file) return;
+ const url = `${_config.soundBase}/${file}`;
+ try {
+ if (!_audioCache[url]) _audioCache[url] = new Audio(url);
+ const a = _audioCache[url]; a.currentTime = 0; a.volume = priority === 'critical' ? 0.8 : 0.5; a.play().catch(() => {});
+ } catch { /* */ }
+ }
+
+ // ── Toasts ──────────────────────────────────────────────
+ function ensureToastContainer() {
+ if (_toastContainer) return;
+ _toastContainer = document.createElement('div');
+ _toastContainer.className = 'openvibe-toast-container';
+ document.body.appendChild(_toastContainer);
+ }
+ function showToast(n) {
+ if (!_preferences.toasts) return;
+ if (_preferences.muted_categories.includes(n.category)) return;
+ ensureToastContainer();
+ while (_toastContainer.children.length >= MAX_TOASTS) _toastContainer.firstChild.remove();
+ const rich = richOf(n);
+ const toast = document.createElement('div');
+ toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;
+ toast.dataset.notifId = n.id || '';
+ const serviceLabel = n.service && n.service !== 'network' ? `<span class="service-badge">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
+ const actions = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input') : [];
+ toast.innerHTML = `
+ <div class="openvibe-toast-icon">${iconHtml(n)}</div>
+ <div class="openvibe-toast-body">
+ <div class="openvibe-toast-title">${esc(n.title)}${serviceLabel}</div>
+ <div class="openvibe-toast-msg">${esc(n.message || '')}</div>
+ ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => `<button data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}
+ </div>
+ <button class="openvibe-toast-close" aria-label="Dismiss">×</button>`;
+ const duration = TOAST_DURATION[n.priority] ?? 5000;
+ if (duration > 0) {
+ const bar = document.createElement('div');
+ bar.className = 'openvibe-toast-progress'; bar.style.width = '100%'; bar.style.transition = `width ${duration}ms linear`;
+ toast.appendChild(bar);
+ requestAnimationFrame(() => { bar.style.width = '0%'; });
+ setTimeout(() => dismissToast(toast), duration);
+ }
+ toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });
+ toast.addEventListener('click', () => {
+ if (n.id) markRead([n.id]);
+ dismissToast(toast);
+ const u = urlOf(n); if (u) navigateTo(u);
+ });
+ toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const a = actions.find(x => String(x.id) === btn.dataset.action);
+ if (a && a.url) { navigateTo(a.url); return; }
+ if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);
+ dismissToast(toast);
+ }));
+ _toastContainer.appendChild(toast);
+ }
+ function dismissToast(el) {
+ if (!el || !el.parentNode) return;
+ el.classList.add('removing');
+ setTimeout(() => el.remove(), 300);
+ }
+
+ // ── Bell / badge ────────────────────────────────────────
+ function createBellEl() {
+ const bell = document.createElement('div');
+ bell.className = 'openvibe-bell';
+ bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
+ bell.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg><span class="badge hidden">0</span>`;
+ bell.addEventListener('click', () => togglePanel());
+ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
+ _bellEl = bell;
+ return bell;
+ }
+ function setBadge(count, { quiet = false } = {}) {
+ const prev = _unreadCount;
+ _unreadCount = Math.max(0, Number(count) || 0);
+ if (_bellEl) {
+ const badge = _bellEl.querySelector('.badge');
+ if (_unreadCount > 0) {
+ badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);
+ badge.classList.remove('hidden');
+ if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 600); }
+ } else badge.classList.add('hidden');
+ }
+ for (const v of _views.values()) {
+ const c = v.root.querySelector('.openvibe-nv-head .count');
+ if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }
+ }
+ if (!quiet) broadcast({ type: 'unread', count: _unreadCount });
+ }
+
+ // ── List views (panel + inbox share this) ───────────────
+ const SVG = {
+ search: '<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>',
+ check: '<svg viewBox="0 0 24 24"><path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z"/></svg>',
+ bellOn: '<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>',
+ bellOff: '<svg viewBox="0 0 24 24"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31 0 0-.01 0-.01.01L18 14.68z"/></svg>',
+ gear: '<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>',
+ open: '<svg viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>',
+ };
+
+ function buildView(root, { mode }) {
+ const v = { root, mode, tab: 'all', category: '', q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };
+ root.innerHTML = `
+ <div class="openvibe-nv-head">
+ <h3>Notifications <span class="count zero">0</span></h3>
+ <div class="openvibe-nv-actions">
+ <button type="button" data-act="read-all" title="Mark all as read">${SVG.check}<span>Mark all read</span></button>
+ <button type="button" data-act="push" title="Desktop / mobile push alerts on this device">${SVG.bellOff}</button>
+ ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open inbox">${SVG.open}</a>` : `<a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification preferences">${SVG.gear}</a>`}
+ </div>
+ </div>
+ <div class="openvibe-nv-note" data-role="note" style="display:none"></div>
+ <div class="openvibe-nv-tools">
+ <div class="openvibe-nv-search">${SVG.search}<input type="search" placeholder="Search notifications…" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>
+ <div class="openvibe-nv-chips">
+ <button type="button" data-tab="all" class="active">All</button>
+ <button type="button" data-tab="unread">Unread</button>
+ <select data-role="category"><option value="">All categories</option>${Object.entries(CATEGORY_LABELS).map(([k, l]) => `<option value="${k}">${l}</option>`).join('')}</select>
+ </div>
+ </div>
+ <div class="openvibe-nv-list" data-role="list"></div>
+ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Open full inbox →</a>` : ''}</div>`;
+ const list = root.querySelector('[data-role="list"]');
+ const input = root.querySelector('input[type="search"]');
+ let qTimer = null;
+ input.addEventListener('input', () => {
+ root.querySelector('.openvibe-nv-search').classList.toggle('has-q', !!input.value);
+ clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);
+ });
+ root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });
+ root.querySelectorAll('[data-tab]').forEach(btn => btn.addEventListener('click', () => {
+ root.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active')); btn.classList.add('active');
+ v.tab = btn.dataset.tab; reload(v);
+ }));
+ root.querySelector('[data-role="category"]').addEventListener('change', (e) => { v.category = e.target.value; reload(v); });
+ root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());
+ root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));
+ // Infinite scroll: load the next page when the user nears the bottom.
+ list.addEventListener('scroll', () => {
+ if (v.loading || !v.hasMore) return;
+ if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);
+ });
+ list.addEventListener('click', (e) => {
+ const dismissBtn = e.target.closest('.dismiss');
+ const item = e.target.closest('.openvibe-notif-item');
+ if (!item) return;
+ const id = item.dataset.id;
+ const ids = (item.dataset.ids || id).split(',').filter(Boolean);
+ if (dismissBtn) { e.stopPropagation(); ids.forEach(x => dismiss(v, x)); return; }
+ const n = v.items.find(x => String(x.id) === id);
+ const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });
+ if (unreadIds.length) markRead(unreadIds);
+ const u = urlOf(n);
+ if (u) navigateTo(u);
+ });
+ refreshPushButton(v);
+ _views.set(root, v);
+ return v;
+ }
+
+ function renderItems(v, { append = false } = {}) {
+ const list = v.root.querySelector('[data-role="list"]');
+ // The same event repeated back to back ("X is live!" six times) reads as one row with a count.
+ const groups = [];
+ for (const n of v.items) {
+ const key = [n.title, n.message || '', n.service || '', n.category || ''].join('\u0001');
+ const g = groups[groups.length - 1];
+ if (g && g.key === key) { g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true; } else groups.push({ key, n, ids: [String(n.id)], unread: !isRead(n) });
+ }
+ const html = groups.map(({ n, ids, unread }) => {
+ try {
+ const cat = CATEGORY_LABELS[n.category] || n.category || '';
+ const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
+ return `<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" title="${esc(urlOf(n) ? 'Open' : '')}">
+ <span class="icon">${iconHtml(n)}</span>
+ <div class="content">
+ <div class="title">${esc(n.title)}${ids.length > 1 ? ` <span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>
+ ${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}
+ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>
+ </div>
+ <button type="button" class="dismiss" title="Dismiss" aria-label="Dismiss">×</button>
+ </div>`;
+ } catch { return ''; }
+ }).join('');
+ if (!append) list.innerHTML = '';
+ if (!v.items.length) {
+ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">${v.q ? '🔎' : '🔔'}</span>${v.q ? 'Nothing matches that search' : (v.tab === 'unread' ? 'You\'re all caught up' : 'No notifications yet')}</div>`;
+ } else {
+ const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();
+ list.insertAdjacentHTML('beforeend', html);
+ if (v.hasMore) list.insertAdjacentHTML('beforeend', `<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>`);
+ const more = list.querySelector('[data-act="more"]'); if (more) more.addEventListener('click', () => loadMore(v));
+ }
+ const st = v.root.querySelector('[data-role="status"]');
+ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';
+ }
+
+ async function fetchPage(v, offset) {
+ const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });
+ if (v.tab === 'unread') params.set('unread_only', '1');
+ if (v.category) params.set('category', v.category);
+ if (v.q) params.set('q', v.q);
+ return apiJson(`/api/notifications?${params}`);
+ }
+ async function reload(v) {
+ const seq = ++v.seq;
+ v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null;
+ const list = v.root.querySelector('[data-role="list"]');
+ list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';
+ try {
+ const data = await fetchPage(v, 0);
+ if (seq !== v.seq) return;
+ v.items = data.notifications || []; v.items.forEach(n => v.ids.add(String(n.id)));
+ v.hasMore = !!data.has_more; v.total = data.total ?? null; v.offset = v.items.length;
+ renderItems(v);
+ } catch (e) {
+ if (seq !== v.seq) return;
+ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">⚠️</span>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</div>`;
+ } finally { if (seq === v.seq) v.loading = false; }
+ }
+ async function loadMore(v) {
+ if (v.loading || !v.hasMore) return;
+ const seq = v.seq; v.loading = true;
+ const more = v.root.querySelector('.openvibe-nv-more'); if (more) more.innerHTML = '<span class="spinner"></span>';
+ try {
+ const data = await fetchPage(v, v.offset);
+ if (seq !== v.seq) return;
+ const fresh = (data.notifications || []).filter(n => !v.ids.has(String(n.id)));
+ fresh.forEach(n => v.ids.add(String(n.id)));
+ v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;
+ // Re-render only the tail to keep the scroll position.
+ const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();
+ if (fresh.length) renderItems({ ...v, items: fresh }, { append: true });
+ else if (v.hasMore) { const list = v.root.querySelector('[data-role="list"]'); list.insertAdjacentHTML('beforeend', '<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>'); list.querySelector('[data-act="more"]').addEventListener('click', () => loadMore(v)); }
+ const st = v.root.querySelector('[data-role="status"]');
+ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';
+ } catch { /* keep what we have */ }
+ finally { v.loading = false; }
+ }
+ function applyReadLocally(v, ids, all) {
+ const set = new Set((ids || []).map(String));
+ for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;
+ v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => { if (all || set.has(el.dataset.id)) el.classList.remove('unread'); });
+ }
+
+ // ── Mutations ───────────────────────────────────────────
+ async function markRead(ids) {
+ ids = (ids || []).map(String).filter(Boolean);
+ if (!ids.length) return;
+ for (const v of _views.values()) applyReadLocally(v, ids, false);
+ broadcast({ type: 'read', ids });
+ try {
+ const data = ids.length === 1
+ ? await apiJson(`/api/notifications/${encodeURIComponent(ids[0])}/read`, { method: 'POST' })
+ : await apiJson('/api/notifications/read-batch', { method: 'POST', body: { ids } });
+ if (data && typeof data.unread === 'number') setBadge(data.unread, { quiet: true }), broadcast({ type: 'unread', count: data.unread });
+ else pollUnread();
+ } catch { pollUnread(); }
+ }
+ async function markAllRead() {
+ for (const v of _views.values()) applyReadLocally(v, [], true);
+ setBadge(0);
+ broadcast({ type: 'read', all: true });
+ try { await apiJson('/api/notifications/read-all', { method: 'POST', body: {} }); } catch { pollUnread(); }
+ }
+ async function dismiss(v, id) {
+ const n = v.items.find(x => String(x.id) === String(id));
+ v.items = v.items.filter(x => String(x.id) !== String(id));
+ v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`)?.remove();
+ if (v.total != null) v.total = Math.max(0, v.total - 1);
+ if (!v.items.length) renderItems(v);
+ try {
+ await apiJson(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });
+ if (n && !isRead(n)) setBadge(Math.max(0, _unreadCount - 1), { quiet: true });
+ } catch { /* */ }
+ }
+
+ // ── Polling / toasts ────────────────────────────────────
+ let _polling = false;
+ async function pollUnread() {
+ if (_polling || !hasSession()) return;
+ _polling = true;
+ _lastPoll = Date.now();
+ try {
+ const data = await apiJson('/api/notifications/unread-count');
+ const prev = _unreadCount;
+ const count = Number(data.count) || 0;
+ setBadge(count);
+ if (count > prev && _lastSeenAt) {
+ // Something new arrived: fetch what's newer than the last thing we saw.
+ const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });
+ const nd = await apiJson(`/api/notifications?${params}`);
+ const fresh = (nd.notifications || []).slice().reverse();
+ for (const n of fresh) { showToast(n); playSound(n.priority); }
+ // Any open list should reflect the new items too.
+ for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);
+ }
+ const newest = await newestCreatedAt();
+ if (newest) _lastSeenAt = newest;
+ } catch { /* offline / signed out — try again next tick */ }
+ finally { _polling = false; }
+ }
+ async function newestCreatedAt() {
+ try { const d = await apiJson('/api/notifications/newest'); const c = d.notification && d.notification.created_at; return c ? String(c) : null; } catch { return null; }
+ }
+ function startPolling() {
+ stopPolling();
+ if (!hasSession()) return;
+ newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });
+ _pollTimer = setInterval(() => { if (!liveOpen() || Date.now() - _lastPoll >= REALTIME_POLL) pollUnread(); }, POLL_INTERVAL);
+ }
+ function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }
+
+ // ── Realtime (notification-live.js) ─────────────────────
+ function liveOpen() { return !!(_live && _live.state().state === 'open'); }
+ /** One re-read for a burst of events (a go-live fan-out, a replay after a reconnect); a gap re-reads open lists too. */
+ function soon(all) {
+ _soonAll = _soonAll || all;
+ if (_soon) return;
+ _soon = setTimeout(() => {
+ _soon = null;
+ if (_polling) { soon(false); return; } // a poll in flight may predate the event: read again after it
+ const lists = _soonAll; _soonAll = false;
+ pollUnread();
+ if (lists) for (const v of _views.values()) reload(v);
+ }, 300);
+ }
+ function liveSrc() {
+ if (_config.liveSrc) return _config.liveSrc;
+ const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);
+ return `${m ? m[1] : 'https://openvibe.network/shared/'}notification-live.js`;
+ }
+ let _liveLoading = null;
+ function loadLive() {
+ if (root.OVNotificationLive) return Promise.resolve(root.OVNotificationLive);
+ return _liveLoading || (_liveLoading = new Promise((ok) => {
+ const sc = document.createElement('script');
+ sc.src = liveSrc(); sc.async = true;
+ sc.onload = () => ok(root.OVNotificationLive || null);
+ sc.onerror = () => { _liveLoading = null; ok(null); };
+ document.head.appendChild(sc);
+ }));
+ }
+ function startRealtime() {
+ if (!_config.realtime || !hasSession() || typeof root.EventSource !== 'function') return;
+ if (_live) { _live.restart(); return; }
+ loadLive().then((L) => {
+ if (!L || _live || !_config.realtime || !hasSession()) return;
+ _live = L.create({
+ ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,
+ token: () => _config.token,
+ credentials: isCrossOrigin() ? 'omit' : 'include',
+ onNotification: () => soon(false),
+ onGap: () => soon(true),
+ });
+ _live.start();
+ });
+ }
+ function stopRealtime() { if (_live) _live.stop(); }
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });
+
+ // ── Panel ───────────────────────────────────────────────
+ function createPanel() {
+ const panel = document.createElement('div');
+ panel.className = 'openvibe-notif-panel';
+ document.body.appendChild(panel);
+ buildView(panel, { mode: 'panel' });
+ document.addEventListener('click', (e) => {
+ if (panel.classList.contains('open') && !panel.contains(e.target) && !(_bellEl && _bellEl.contains(e.target))) closePanel();
+ });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel.classList.contains('open')) closePanel(); });
+ _panelEl = panel;
+ return panel;
+ }
+ function closePanel() { _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); }
+ function togglePanel() {
+ if (!_panelEl) createPanel();
+ const opening = !_panelEl.classList.contains('open');
+ if (opening && window.OpenVibePanels && !_panelEl.__registered) { _panelEl.__registered = true; window.OpenVibePanels.register({ el: _panelEl, openClass: 'open', id: 'notifications', close: () => { if (_panelEl.classList.contains('open')) togglePanel(); } }); window.OpenVibePanels.closeAll(_panelEl); }
+ if (opening) {
+ // Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).
+ const bar = _bellEl && _bellEl.closest('nav, header, .navbar');
+ const bottom = bar ? bar.getBoundingClientRect().bottom : (_bellEl ? _bellEl.getBoundingClientRect().bottom : 56);
+ _panelEl.style.setProperty('--ovn-top', Math.max(8, Math.round(bottom) + 6) + 'px');
+ if (!_panelEl.__esc) { _panelEl.__esc = (e) => { if (e.key === 'Escape' && _panelEl.classList.contains('open')) togglePanel(); }; document.addEventListener('keydown', _panelEl.__esc); }
+ }
+ _panelEl.classList.toggle('open', opening);
+ _bellEl?.classList.toggle('open', opening);
+ if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }
+ }
+
+ // ── Web Push ────────────────────────────────────────────
+ function pushSupported() { return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window && window.isSecureContext; }
+ function urlBase64ToUint8Array(b64) {
+ const padding = '='.repeat((4 - (b64.length % 4)) % 4);
+ const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');
+ const raw = window.atob(base64); const out = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
+ return out;
+ }
+ async function currentSubscription() {
+ if (!pushSupported()) return null;
+ try { const reg = await navigator.serviceWorker.getRegistration(_config.swPath); return reg ? await reg.pushManager.getSubscription() : null; } catch { return null; }
+ }
+ async function pushStatus() {
+ const supported = pushSupported();
+ const sub = supported ? await currentSubscription() : null;
+ return { supported, permission: supported ? Notification.permission : 'unsupported', subscribed: !!sub };
+ }
+ async function enablePush() {
+ if (!pushSupported()) throw new Error('This browser does not support push notifications');
+ if (!hasSession()) throw new Error('Sign in first');
+ const perm = await Notification.requestPermission();
+ if (perm !== 'granted') throw new Error(perm === 'denied' ? 'Notifications are blocked for this site in your browser settings' : 'Permission not granted');
+ const reg = await navigator.serviceWorker.register(_config.swPath, { scope: '/' });
+ await navigator.serviceWorker.ready;
+ const { publicKey } = await apiJson('/api/push/vapid-key');
+ if (!publicKey) throw new Error('Push is not configured on the server');
+ let sub = await reg.pushManager.getSubscription();
+ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });
+ await apiJson('/api/push/subscribe', { method: 'POST', body: { subscription: { ...sub.toJSON(), userAgent: navigator.userAgent.slice(0, 200) } } });
+ try { localStorage.setItem('ov_push_enabled', '1'); } catch { /* */ }
+ return true;
+ }
+ async function disablePush() {
+ const sub = await currentSubscription();
+ if (sub) {
+ try { await apiJson('/api/push/unsubscribe', { method: 'POST', body: { endpoint: sub.endpoint } }); } catch { /* */ }
+ try { await sub.unsubscribe(); } catch { /* */ }
+ }
+ try { localStorage.removeItem('ov_push_enabled'); } catch { /* */ }
+ return true;
+ }
+ async function refreshPushButton(v) {
+ const btn = v && v.root.querySelector('[data-act="push"]');
+ if (!btn) return;
+ const st = await pushStatus();
+ if (!st.supported) { btn.style.display = 'none'; return; }
+ btn.style.display = '';
+ btn.classList.toggle('on', st.subscribed);
+ btn.innerHTML = st.subscribed ? `${SVG.bellOn}<span>Push on</span>` : `${SVG.bellOff}<span>Enable push</span>`;
+ btn.title = st.subscribed ? 'Push alerts are on for this device — click to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get desktop / mobile alerts on this device even when the tab is closed');
+ btn.disabled = st.permission === 'denied' && !st.subscribed;
+ }
+ async function togglePush(v) {
+ const note = v.root.querySelector('[data-role="note"]');
+ const say = (msg, ms = 4000) => { if (!note) return; note.textContent = msg; note.style.display = ''; clearTimeout(note._t); note._t = setTimeout(() => { note.style.display = 'none'; }, ms); };
+ try {
+ const st = await pushStatus();
+ if (st.subscribed) { await disablePush(); say('Push alerts turned off for this device.'); }
+ else { await enablePush(); say('Push alerts enabled — you\'ll get alerts here even when this site is closed.'); }
+ } catch (e) { say(e.message || 'Could not change push setting', 6000); }
+ for (const view of _views.values()) refreshPushButton(view);
+ }
+
+ // ── Public API ──────────────────────────────────────────
+ const OpenVibeNotifications = {
+ init(opts = {}) {
+ Object.assign(_config, opts);
+ injectStyles();
+ if (opts.preferences) Object.assign(_preferences, opts.preferences);
+ channel();
+ startPolling();
+ startRealtime();
+ // If the user enabled push on this device before, make sure the worker is registered.
+ try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }
+ },
+ destroy() {
+ stopPolling();
+ stopRealtime();
+ _toastContainer?.remove(); _panelEl?.remove();
+ _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;
+ },
+ /** Create and inject the bell icon. Returns the DOM element. */
+ createBell(container) {
+ injectStyles();
+ const bell = createBellEl();
+ if (container) container.appendChild(bell);
+ pollUnread();
+ return bell;
+ },
+ /** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */
+ renderInbox(container, opts = {}) {
+ injectStyles();
+ if (!container) return null;
+ container.classList.add('openvibe-notif-inbox');
+ const v = buildView(container, { mode: 'inbox' });
+ if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === opts.tab)); }
+ reload(v);
+ return v;
+ },
+ /** Programmatically show a toast (real-time events). Marks nothing on the server. */
+ push(notification) {
+ showToast(notification);
+ playSound(notification.priority);
+ if (!notification.id) return;
+ setBadge(_unreadCount + 1);
+ },
+ /** Re-fetch the badge and any open lists (call after an in-page action). */
+ refresh() { pollUnread(); for (const v of _views.values()) reload(v); },
+ markRead(ids) { return markRead(Array.isArray(ids) ? ids : [ids]); },
+ markAllRead,
+ setPreferences(prefs) { Object.assign(_preferences, prefs); },
+ setToken(token) {
+ _config.token = token;
+ if (token || !isCrossOrigin()) { startPolling(); startRealtime(); } else { stopPolling(); stopRealtime(); setBadge(0, { quiet: true }); }
+ },
+ /** The realtime feed's state ({ state: 'off' | 'ticket' | 'connecting' | 'open' | 'backoff' | 'hidden' | 'blocked' | 'failed' | 'signed-out' | 'unavailable', … }), or null when off. */
+ realtimeState() { return _live ? _live.state() : null; },
+ togglePanel, closePanel,
+ enablePush, disablePush, pushStatus,
+ get unreadCount() { return _unreadCount; },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;
+ else root.OpenVibeNotifications = OpenVibeNotifications;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+/* OpenVibe — shared Web Push service worker.
+ * Served by every OpenVibe site at /openvibe-sw.js (must be same-origin, scope "/").
+ * Payload (from openvibe.network push-service): { title, body, icon, url, tag } */
+self.addEventListener('install', () => self.skipWaiting());
+self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
+
+self.addEventListener('push', (event) => {
+ let data = {};
+ try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'OpenVibe', body: event.data ? event.data.text() : '' }; }
+ const title = data.title || 'OpenVibe';
+ const options = {
+ body: data.body || '',
+ icon: data.icon || 'https://openvibe.network/assets/logo-192.png',
+ badge: data.badge || 'https://openvibe.network/assets/logo-72.png',
+ tag: data.tag || 'openvibe',
+ renotify: !!data.renotify,
+ data: { url: data.url || 'https://openvibe.network/notifications', id: data.id || null },
+ requireInteraction: false,
+ };
+ event.waitUntil(self.registration.showNotification(title, options));
+});
+
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+ const url = (event.notification.data && event.notification.data.url) || 'https://openvibe.network/notifications';
+ event.waitUntil((async () => {
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ let target = null;
+ try { target = new URL(url); } catch { /* */ }
+ for (const c of all) {
+ try {
+ if (target && new URL(c.url).origin === target.origin && 'focus' in c) {
+ await c.focus();
+ if ('navigate' in c) { try { await c.navigate(url); } catch { /* cross-origin navigate blocked */ } }
+ return;
+ }
+ } catch { /* */ }
+ }
+ await self.clients.openWindow(url);
+ })());
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,272 @@
@@ -0,0 +1,272 @@
+/**
+ * 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') ,
+ };
+ // [dx, dy] in glyph units that move each glyph's drawn bounds onto the centre of the ring. Generated.
+ const OFFSETS = {"ov":[0,-0.5],"network":[0,0.5],"tools":[1.26,0.24],"games":[0,-1.52],"community":[0,-0.45],"chat":[0,-1],"wiki":[0,-0.25],"reviews":[0,0.6],"tips":[0,-0.76],"vip":[0,-0.75],"trade":[0,-1],"deals":[0,-1],"coupons":[0,-0.5],"stream":[0,0.95],"download":[0,0.25],"audio":[0.7,-0.35],"pdf":[-0.25,0],"docs":[-0.25,0],"logo":[-1.25,-0.5],"ssl":[0,-0.25],"whois":[-0.5,-0.5],"search":[-0.5,-0.5],"food":[0.25,0],"account":[0,-0.4],"bell":[0,-1.37],"error":[-0.01,0.25]};
+ 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-box:view-box;transform-origin:12px 12px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}
+.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:transform .25s cubic-bezier(.2,1.4,.3,1),fill .2s}
+.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}
+.ov-icon[style*="--ovi-size:2"] .ovi-glyph,.ov-icon[data-size^="2"] .ovi-glyph{stroke-width:2.3}
+.ov-icon[style*="--ovi-size:2"] .ovi-ring,.ov-icon[data-size^="2"] .ovi-ring{stroke-width:3;opacity:.34}
+.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}
+.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}
+.ov-icon[data-fx=none] .ovi-comet{display:none}
+.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}}`;
+
+ // BEGIN generated solid glyphs (scripts/build-nav-icons.py)
+ // Font Awesome Free 6 solid glyphs (CC BY 4.0) for the ring icons; viewBox 0 0 <w> 512.
+ const SOLID = {
+ network: [512, 'M418 169Q445 162 462 141Q479 120 480 91Q479 57 457 34Q434 12 400 11Q367 12 344 34Q322 55 320 88L136 162Q114 140 80 139Q46 140 23 162Q1 185 0 219Q1 253 23 276Q46 298 80 299Q98 299 114 291L260 419Q256 430 256 443Q257 477 279 500Q302 522 336 523Q370 522 393 500Q415 477 416 443Q414 400 381 377L418 169ZM156 243Q160 233 160 222L344 148Q349 153 355 158L318 365Q309 367 302 371L156 243Z'],
+ live: [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],
+ tools: [512, 'M79 16Q62 5 47 18L7 58Q-6 73 5 90L85 194Q92 203 104 203H158L267 312Q256 334 260 359Q263 383 281 402L393 514Q403 523 416 523Q429 523 439 514L503 450Q512 440 512 427Q512 414 503 404L391 292Q372 274 348 271Q323 267 301 278L192 169V115Q192 103 183 96L79 16ZM20 407Q0 427 0 455Q1 484 20 503Q39 522 68 523Q96 523 116 503L234 385Q222 353 230 320L168 258L20 407ZM512 155Q512 139 509 125Q506 116 499 114Q491 113 485 119L421 182Q416 187 409 187H352Q337 186 336 171V114Q336 107 341 102L405 38Q411 32 409 24Q407 17 399 14Q384 11 368 11Q307 13 266 53Q226 94 224 155V156L309 241Q337 234 364 241Q392 249 413 270L429 286Q466 268 489 233Q511 199 512 155ZM56 443Q58 421 80 419Q102 421 104 443Q102 465 80 467Q58 465 56 443Z'],
+ media: [640, 'M256 11H576H256H576Q603 12 621 30Q639 48 640 75V299Q639 326 621 344Q603 362 576 363H256Q229 362 211 344Q193 326 192 299V75Q193 48 211 30Q229 12 256 11ZM476 118Q469 107 456 107Q443 107 436 118L380 202L363 180Q355 171 344 171Q333 171 325 180L261 260Q252 272 258 285Q265 298 280 299H360H552Q566 299 573 286Q579 274 572 262L476 118ZM336 107Q336 93 327 84Q318 75 304 75Q290 75 281 84Q272 93 272 107Q272 121 281 130Q290 139 304 139Q318 139 327 130Q336 121 336 107ZM64 139H160H64H160V395V427Q160 441 169 450Q178 459 192 459H320Q334 459 343 450Q352 441 352 427V395H512V459Q511 486 493 504Q475 522 448 523H64Q37 522 19 504Q1 486 0 459V203Q1 176 19 158Q37 140 64 139ZM72 203Q57 204 56 219V235Q57 250 72 251H88Q103 250 104 235V219Q103 204 88 203H72ZM72 307Q57 308 56 323V339Q57 354 72 355H88Q103 354 104 339V323Q103 308 88 307H72ZM72 411Q57 412 56 427V443Q57 458 72 459H88Q103 458 104 443V427Q103 412 88 411H72ZM408 427V443V427V443Q409 458 424 459H440Q455 458 456 443V427Q455 412 440 411H424Q409 412 408 427Z'],
+ games: [640, 'M192 75Q110 77 56 131Q2 185 0 267Q2 349 56 403Q110 457 192 459H448Q530 457 584 403Q638 349 640 267Q638 185 584 131Q530 77 448 75H192ZM496 179Q519 180 531 199Q541 219 531 239Q519 258 496 259Q473 258 461 239Q451 219 461 199Q473 180 496 179ZM392 315Q393 292 412 280Q432 270 452 280Q471 292 472 315Q471 338 452 350Q432 360 412 350Q393 338 392 315ZM168 211Q170 189 192 187Q214 189 216 211V243H248Q270 245 272 267Q270 289 248 291H216V323Q214 345 192 347Q170 345 168 323V291H136Q114 289 112 267Q114 245 136 243H168V211Z'],
+ community: [640, 'M72 99Q73 67 100 51Q128 35 156 51Q183 67 184 99Q183 131 156 147Q128 163 100 147Q73 131 72 99ZM64 257Q49 274 48 299Q49 324 64 341V257ZM208 207Q162 249 160 315Q161 367 192 406V427Q192 441 183 450Q174 459 160 459H96Q82 459 73 450Q64 441 64 427V400Q35 386 18 360Q0 333 0 299Q1 251 33 220Q64 188 112 187H144Q181 188 208 207ZM448 427V406V427V406Q479 367 480 315Q478 249 432 207Q459 188 496 187H528Q576 188 607 220Q639 251 640 299Q640 333 622 360Q605 386 576 400V427Q576 441 567 450Q558 459 544 459H480Q466 459 457 450Q448 441 448 427ZM456 99Q457 67 484 51Q512 35 540 51Q567 67 568 99Q567 131 540 147Q512 163 484 147Q457 131 456 99ZM576 257V341V257V341Q592 324 592 299Q592 274 576 257ZM320 43Q356 44 375 75Q393 107 375 139Q356 170 320 171Q284 170 265 139Q247 107 265 75Q284 44 320 43ZM240 315Q241 340 256 357V273Q241 290 240 315ZM384 273V357V273V357Q400 340 400 315Q400 290 384 273ZM448 315Q448 349 430 376Q413 402 384 416V459Q384 473 375 482Q366 491 352 491H288Q274 491 265 482Q256 473 256 459V416Q227 402 210 376Q192 349 192 315Q193 267 225 236Q256 204 304 203H336Q384 204 415 236Q447 267 448 315Z'],
+ chat: [640, 'M208 363Q296 361 355 311Q414 262 416 187Q414 112 355 63Q296 13 208 11Q120 13 61 63Q2 112 0 187Q1 246 40 290Q34 305 25 315Q18 324 12 329Q9 332 8 333Q7 334 7 334H6Q-2 341 1 352Q5 362 16 363Q50 362 78 351Q92 345 103 339Q150 362 208 363ZM448 187Q445 273 384 329Q322 385 232 394Q251 451 305 486Q359 522 432 523Q490 522 537 499Q548 505 562 511Q590 522 624 523Q635 522 639 512Q642 501 633 494Q633 494 633 493Q632 493 632 493Q631 492 628 489Q622 484 615 475Q606 464 600 450Q639 406 640 347Q638 276 584 227Q530 178 447 171Q448 179 448 187Z'],
+ codes: [640, 'M393 12Q380 9 368 15Q357 21 353 34L225 482Q222 495 228 507Q234 518 247 522Q260 525 272 519Q283 513 287 500L415 52Q418 39 412 27Q406 16 393 12ZM473 132Q464 142 464 155Q464 168 473 178L563 267L473 356Q464 366 464 379Q464 392 473 402Q483 411 496 411Q509 411 519 402L631 290Q640 280 640 267Q640 254 631 244L519 132Q509 123 496 123Q483 123 473 132ZM167 132Q157 123 144 123Q131 123 121 132L9 244Q0 254 0 267Q0 280 9 290L121 402Q131 411 144 411Q157 411 167 402Q176 392 176 379Q176 366 167 356L77 267L167 178Q176 168 176 155Q176 142 167 132Z'],
+ code: [640, 'M393 12Q380 9 368 15Q357 21 353 34L225 482Q222 495 228 507Q234 518 247 522Q260 525 272 519Q283 513 287 500L415 52Q418 39 412 27Q406 16 393 12ZM473 132Q464 142 464 155Q464 168 473 178L563 267L473 356Q464 366 464 379Q464 392 473 402Q483 411 496 411Q509 411 519 402L631 290Q640 280 640 267Q640 254 631 244L519 132Q509 123 496 123Q483 123 473 132ZM167 132Q157 123 144 123Q131 123 121 132L9 244Q0 254 0 267Q0 280 9 290L121 402Q131 411 144 411Q157 411 167 402Q176 392 176 379Q176 366 167 356L77 267L167 178Q176 168 176 155Q176 142 167 132Z'],
+ blog: [512, 'M368 29 313 85 368 29 313 85 438 210 494 155Q510 137 510 115Q510 93 494 75L448 29Q430 13 408 13Q386 13 368 29ZM288 106 279 108 288 106 279 108 135 152Q104 162 92 193L4 457Q-2 475 11 489L165 336Q160 326 160 315Q161 295 174 281Q188 268 208 267Q228 268 242 281Q255 295 256 315Q255 335 242 349Q228 362 208 363Q197 363 187 358L34 512Q48 525 66 519L330 431Q361 419 371 388L415 244L417 235L288 106Z'],
+ wiki: [448, 'M96 11Q55 12 28 39Q1 66 0 107V427Q1 468 28 495Q55 522 96 523H384H416Q430 523 439 514Q448 505 448 491Q448 477 439 468Q430 459 416 459V395Q430 395 439 386Q448 377 448 363V43Q448 29 439 20Q430 11 416 11H384ZM96 395H352H96H352V459H96Q82 459 73 450Q64 441 64 427Q64 413 73 404Q82 395 96 395ZM128 155Q129 140 144 139H336Q351 140 352 155Q351 170 336 171H144Q129 170 128 155ZM144 203H336H144H336Q351 204 352 219Q351 234 336 235H144Q129 234 128 219Q129 204 144 203Z'],
+ news: [512, 'M96 107Q97 80 115 62Q133 44 160 43H448Q475 44 493 62Q511 80 512 107V427Q511 454 493 472Q475 490 448 491H80Q46 490 23 468Q1 445 0 411V139Q0 125 9 116Q18 107 32 107Q46 107 55 116Q64 125 64 139V411Q65 426 80 427Q95 426 96 411V107ZM160 131V211V131V211Q162 233 184 235H296Q318 233 320 211V131Q318 109 296 107H184Q162 109 160 131ZM368 123Q369 138 384 139H432Q447 138 448 123Q447 108 432 107H384Q369 108 368 123ZM368 219Q369 234 384 235H432Q447 234 448 219Q447 204 432 203H384Q369 204 368 219ZM160 315Q161 330 176 331H432Q447 330 448 315Q447 300 432 299H176Q161 300 160 315ZM160 411Q161 426 176 427H432Q447 426 448 411Q447 396 432 395H176Q161 396 160 411Z'],
+ reviews: [576, 'M317 29Q308 12 288 11Q269 12 259 29L195 161L51 183Q32 186 26 204Q20 223 34 237L138 340L113 486Q111 505 126 517Q142 528 160 519L288 451L417 519Q434 528 450 517Q466 505 463 486L439 340L543 237Q556 223 551 204Q544 186 525 183L381 161L317 29Z'],
+ tips: [512, 'M512 91Q511 118 474 139Q428 164 351 170Q346 167 340 165Q279 140 192 139Q180 139 168 140L166 139Q129 118 128 91Q130 57 184 34Q238 12 320 11Q402 12 456 34Q510 57 512 91ZM161 172Q176 171 192 171Q239 171 278 180Q318 188 345 202Q383 223 384 251Q384 257 382 263Q374 283 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q346 299 346 299Q346 299 346 299Q319 314 279 322Q240 331 192 331Q100 330 44 302Q41 300 38 299Q1 278 0 251Q1 224 36 204Q71 184 128 176Q144 173 161 172ZM416 251Q414 218 392 198Q435 191 468 177Q493 167 512 152V187Q511 217 468 238Q446 249 416 256Q416 256 416 256Q416 254 416 251ZM384 347Q383 374 346 395Q344 396 342 397Q341 397 340 398Q284 426 192 427Q144 427 105 418Q65 410 38 395Q1 374 0 347V312Q19 327 44 337Q105 362 192 363Q279 362 340 337Q352 332 363 326Q372 321 380 315Q381 314 383 313Q383 312 384 312V315V321V347ZM416 347V315V347V315V289Q445 283 468 273Q493 263 512 248V283Q512 299 497 314Q471 339 416 352Q416 350 416 347ZM192 459Q279 458 340 433Q365 423 384 408V443Q382 477 328 500Q274 522 192 523Q110 522 56 500Q2 477 0 443V408Q19 423 44 433Q105 458 192 459Z'],
+ vip: [576, 'M309 117Q327 105 328 83Q328 66 316 55Q305 43 288 43Q271 43 260 55Q248 66 248 83Q249 106 267 117L210 232Q202 245 188 249Q174 252 161 242L72 171Q80 161 80 147Q80 130 68 119Q57 107 40 107Q23 107 12 119Q0 130 0 147Q0 164 12 175Q23 187 40 187Q40 187 40 187Q41 187 41 187L86 438Q91 462 108 476Q126 491 149 491H427Q450 491 468 476Q485 462 490 438L535 187Q535 187 536 187Q536 187 536 187Q553 187 564 175Q576 164 576 147Q576 130 564 119Q553 107 536 107Q519 107 508 119Q496 130 496 147Q496 161 504 171L415 242Q402 252 388 249Q374 245 366 232L309 117Z'],
+ trade: [512, 'M64 75Q64 61 55 52Q46 43 32 43Q18 43 9 52Q0 61 0 75V411Q1 445 23 468Q46 490 80 491H480Q494 491 503 482Q512 473 512 459Q512 445 503 436Q494 427 480 427H80Q65 426 64 411V75ZM471 162Q480 152 480 139Q480 126 471 116Q461 107 448 107Q435 107 425 116L320 222L263 164Q253 155 240 155Q227 155 217 164L105 276Q96 286 96 299Q96 312 105 322Q115 331 128 331Q141 331 151 322L240 232L297 290Q307 299 320 299Q333 299 343 290L471 162Z'],
+ host: [512, 'M64 43Q37 44 19 62Q1 80 0 107V171Q1 198 19 216Q37 234 64 235H448Q475 234 493 216Q511 198 512 171V107Q511 80 493 62Q475 44 448 43H64ZM344 115Q366 117 368 139Q366 161 344 163Q322 161 320 139Q322 117 344 115ZM392 139Q394 117 416 115Q438 117 440 139Q438 161 416 163Q394 161 392 139ZM64 299Q37 300 19 318Q1 336 0 363V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V363Q511 336 493 318Q475 300 448 299H64ZM344 371Q366 373 368 395Q366 417 344 419Q322 417 320 395Q322 373 344 371ZM400 395Q402 373 424 371Q446 373 448 395Q446 417 424 419Q402 417 400 395Z'],
+ deals: [448, 'M0 91V241V91V241Q0 267 19 286L195 462Q214 481 240 481Q265 481 285 462L419 328Q437 309 437 283Q437 258 419 238L243 62Q224 43 197 43H48Q28 44 14 57Q1 71 0 91ZM112 123Q126 123 135 132Q144 141 144 155Q144 169 135 178Q126 187 112 187Q98 187 89 178Q80 169 80 155Q80 141 89 132Q98 123 112 123Z'],
+ coupons: [576, 'M64 75Q37 76 19 94Q1 112 0 139V203Q2 217 16 222Q46 233 48 267Q46 301 16 312Q2 317 0 331V395Q1 422 19 440Q37 458 64 459H512Q539 458 557 440Q575 422 576 395V331Q574 317 560 312Q530 301 528 267Q530 233 560 222Q574 217 576 203V139Q575 112 557 94Q539 76 512 75H64ZM128 187V347V187V347Q129 362 144 363H432Q447 362 448 347V187Q447 172 432 171H144Q129 172 128 187ZM96 171Q96 157 105 148Q114 139 128 139H448Q462 139 471 148Q480 157 480 171V363Q480 377 471 386Q462 395 448 395H128Q114 395 105 386Q96 377 96 363V171Z'],
+ stream: [512, 'M192 43Q192 29 201 20Q210 11 224 11Q304 12 369 50Q434 89 473 154Q511 219 512 299Q512 313 503 322Q494 331 480 331Q466 331 457 322Q448 313 448 299Q447 236 417 186Q388 135 337 106Q287 76 224 75Q210 75 201 66Q192 57 192 43ZM61 232 165 336 61 232 165 336 193 307Q192 303 192 299Q192 285 201 276Q210 267 224 267Q238 267 247 276Q256 285 256 299Q256 313 247 322Q238 331 224 331Q220 331 216 330L187 358L291 462Q302 474 300 488Q298 502 284 509Q249 523 208 523Q120 521 61 462Q2 403 0 315Q0 274 14 239Q21 225 35 223Q49 221 61 232ZM224 107Q306 109 360 163Q414 217 416 299Q416 313 407 322Q398 331 384 331Q370 331 361 322Q352 313 352 299Q351 245 315 208Q278 172 224 171Q210 171 201 162Q192 153 192 139Q192 125 201 116Q210 107 224 107Z'],
+ video: [576, 'M0 139Q1 112 19 94Q37 76 64 75H320Q347 76 365 94Q383 112 384 139V395Q383 422 365 440Q347 458 320 459H64Q37 458 19 440Q1 422 0 395V139ZM559 111Q575 120 576 139V395Q575 414 559 423Q542 432 526 422L430 358L416 348V331V203V186L430 176L526 112Q542 103 559 111Z'],
+ download: [512, 'M288 43Q288 29 279 20Q270 11 256 11Q242 11 233 20Q224 29 224 43V286L151 212Q141 203 128 203Q115 203 105 212Q96 222 96 235Q96 248 105 258L233 386Q243 395 256 395Q269 395 279 386L407 258Q416 248 416 235Q416 222 407 212Q397 203 384 203Q371 203 361 212L288 286V43ZM64 363Q37 364 19 382Q1 400 0 427V459Q1 486 19 504Q37 522 64 523H448Q475 522 493 504Q511 486 512 459V427Q511 400 493 382Q475 364 448 363H347L301 408Q281 427 256 427Q230 427 211 408L166 363H64ZM432 419Q454 421 456 443Q454 465 432 467Q410 465 408 443Q410 421 432 419Z'],
+ upload: [512, 'M288 120V363V120V363Q288 377 279 386Q270 395 256 395Q242 395 233 386Q224 377 224 363V120L151 194Q141 203 128 203Q115 203 105 194Q96 184 96 171Q96 158 105 148L233 20Q243 11 256 11Q269 11 279 20L407 148Q416 158 416 171Q416 184 407 194Q397 203 384 203Q371 203 361 194L288 120ZM64 363H192H64H192Q193 390 211 408Q229 426 256 427Q283 426 301 408Q319 390 320 363H448Q475 364 493 382Q511 400 512 427V459Q511 486 493 504Q475 522 448 523H64Q37 522 19 504Q1 486 0 459V427Q1 400 19 382Q37 364 64 363ZM432 467Q454 465 456 443Q454 421 432 419Q410 421 408 443Q410 465 432 467Z'],
+ image: [512, 'M0 107Q1 80 19 62Q37 44 64 43H448Q475 44 493 62Q511 80 512 107V427Q511 454 493 472Q475 490 448 491H64Q37 490 19 472Q1 454 0 427V107ZM324 214Q316 203 304 203Q291 203 284 214L197 341L171 308Q163 299 152 299Q141 299 133 308L69 388Q61 400 66 413Q73 426 88 427H184H424Q438 427 445 414Q451 401 444 390L324 214ZM112 203Q139 202 154 179Q166 155 154 131Q139 108 112 107Q85 108 70 131Q58 155 70 179Q85 202 112 203Z'],
+ audio: [512, 'M499 17Q512 27 512 43V115V379Q511 413 484 436Q457 458 416 459Q375 458 348 436Q321 413 320 379Q321 345 348 322Q375 300 416 299Q433 299 448 304V158L192 235V443Q191 477 164 500Q137 522 96 523Q55 522 28 500Q1 477 0 443Q1 409 28 386Q55 364 96 363Q113 363 128 368V211V139Q129 116 151 108L471 12Q486 8 499 17Z'],
+ pdf: [512, 'M0 75Q1 48 19 30Q37 12 64 11H224V139Q224 153 233 162Q242 171 256 171H384V315H176Q149 316 131 334Q113 352 112 379V523H64Q37 522 19 504Q1 486 0 459V75ZM384 139H256H384H256V11L384 139ZM176 363H208H176H208Q232 364 248 379Q263 395 264 419Q263 443 248 459Q232 474 208 475H192V507Q191 522 176 523Q161 522 160 507V459V379Q161 364 176 363ZM208 443Q230 441 232 419Q230 397 208 395H192V443H208ZM304 363H336H304H336Q356 364 370 377Q383 391 384 411V475Q383 495 370 509Q356 522 336 523H304Q289 522 288 507V379Q289 364 304 363ZM336 491Q351 490 352 475V411Q351 396 336 395H320V491H336ZM416 379Q417 364 432 363H480Q495 364 496 379Q495 394 480 395H448V427H480Q495 428 496 443Q495 458 480 459H448V507Q447 522 432 523Q417 522 416 507V443V379Z'],
+ docs: [384, 'M64 11Q37 12 19 30Q1 48 0 75V459Q1 486 19 504Q37 522 64 523H320Q347 522 365 504Q383 486 384 459V171H256Q242 171 233 162Q224 153 224 139V11H64ZM256 11V139V11V139H384L256 11ZM112 267H272H112H272Q287 268 288 283Q287 298 272 299H112Q97 298 96 283Q97 268 112 267ZM112 331H272H112H272Q287 332 288 347Q287 362 272 363H112Q97 362 96 347Q97 332 112 331ZM112 395H272H112H272Q287 396 288 411Q287 426 272 427H112Q97 426 96 411Q97 396 112 395Z'],
+ text: [448, 'M254 64Q245 44 224 43Q203 44 194 64L58 427H32Q18 427 9 436Q0 445 0 459Q0 473 9 482Q18 491 32 491H128Q142 491 151 482Q160 473 160 459Q160 445 151 436Q142 427 128 427H126L144 379H304L322 427H320Q306 427 297 436Q288 445 288 459Q288 473 297 482Q306 491 320 491H416Q430 491 439 482Q448 473 448 459Q448 445 439 436Q430 427 416 427H390L254 64ZM280 315H168H280H168L224 166L280 315Z'],
+ dns: [512, 'M352 267Q352 300 349 331H163Q160 300 160 267Q160 234 163 203H349Q352 234 352 267ZM381 203H504H381H504Q512 234 512 267Q512 300 504 331H381Q384 300 384 267Q384 234 381 203ZM493 171H377H493H377Q361 73 321 19Q381 36 426 75Q470 115 493 171ZM344 171H168H344H168Q177 116 195 76Q211 41 228 25Q245 10 256 11Q267 10 284 25Q301 41 317 76Q335 116 344 171ZM135 171H19H135H19Q42 115 86 75Q131 36 191 19Q151 73 135 171ZM8 203H131H8H131Q128 234 128 267Q128 300 131 331H8Q0 300 0 267Q0 234 8 203ZM195 458Q177 418 168 363H344Q335 418 317 458Q301 493 284 509Q267 524 256 523Q245 524 228 509Q211 493 195 458ZM135 363Q151 461 191 515Q131 498 86 459Q42 419 19 363H135ZM493 363Q470 419 426 459Q381 498 322 515Q361 461 377 363H493Z'],
+ ip: [384, 'M216 510Q243 477 282 421Q321 366 352 307Q382 248 384 203Q382 121 328 67Q274 13 192 11Q110 13 56 67Q2 121 0 203Q2 248 32 307Q63 366 102 421Q141 477 168 510Q178 522 192 522Q206 522 216 510ZM192 139Q228 140 247 171Q265 203 247 235Q228 266 192 267Q156 266 137 235Q119 203 137 171Q156 140 192 139Z'],
+ ssl: [448, 'M144 155V203V155V203H304V155Q303 121 281 98Q258 76 224 75Q190 76 167 98Q145 121 144 155ZM80 203V155V203V155Q82 94 122 53Q163 13 224 11Q285 13 326 53Q366 94 368 155V203H384Q411 204 429 222Q447 240 448 267V459Q447 486 429 504Q411 522 384 523H64Q37 522 19 504Q1 486 0 459V267Q1 240 19 222Q37 204 64 203H80Z'],
+ ping: [640, 'M128 75Q128 61 137 52Q146 43 160 43H320Q334 43 343 52Q352 61 352 75V427H448V267Q448 253 457 244Q466 235 480 235H608Q622 235 631 244Q640 253 640 267Q640 281 631 290Q622 299 608 299H512V459Q512 473 503 482Q494 491 480 491H320Q306 491 297 482Q288 473 288 459V107H192V267Q192 281 183 290Q174 299 160 299H32Q18 299 9 290Q0 281 0 267Q0 253 9 244Q18 235 32 235H128V75Z'],
+ whois: [512, 'M416 219Q415 289 376 342L503 468Q512 478 512 491Q512 504 503 514Q493 523 480 523Q467 523 457 514L331 387Q278 426 208 427Q120 425 61 366Q2 307 0 219Q2 131 61 72Q120 13 208 11Q296 13 355 72Q414 131 416 219ZM208 363Q247 363 280 344Q313 325 333 291Q352 257 352 219Q352 181 333 147Q313 113 280 94Q247 75 208 75Q169 75 136 94Q103 113 83 147Q64 181 64 219Q64 257 83 291Q103 325 136 344Q169 363 208 363Z'],
+ search: [512, 'M416 219Q415 289 376 342L503 468Q512 478 512 491Q512 504 503 514Q493 523 480 523Q467 523 457 514L331 387Q278 426 208 427Q120 425 61 366Q2 307 0 219Q2 131 61 72Q120 13 208 11Q296 13 355 72Q414 131 416 219ZM208 363Q247 363 280 344Q313 325 333 291Q352 257 352 219Q352 181 333 147Q313 113 280 94Q247 75 208 75Q169 75 136 94Q103 113 83 147Q64 181 64 219Q64 257 83 291Q103 325 136 344Q169 363 208 363Z'],
+ map: [576, 'M384 487 192 432 384 487 192 432V47L384 102V487ZM416 486V99V486V99L543 49Q556 44 565 51Q575 58 576 71V406Q575 421 561 428L416 486ZM15 106 160 48 15 106 160 48V435L33 486Q20 490 11 483Q1 476 0 463V128Q1 113 15 106Z'],
+ food: [448, 'M416 11Q406 10 376 25Q345 39 318 77Q290 116 288 187V299Q289 326 307 344Q325 362 352 363H384V491Q384 505 393 514Q402 523 416 523Q430 523 439 514Q448 505 448 491V363V251V43Q448 29 439 20Q430 11 416 11ZM64 27Q63 14 50 11Q36 11 32 24L2 160Q0 169 0 179Q1 214 23 238Q46 263 80 267V491Q80 505 89 514Q98 523 112 523Q126 523 135 514Q144 505 144 491V267Q178 263 201 238Q223 214 224 179Q224 169 222 160L192 24Q188 11 174 11Q161 14 160 27V161Q159 170 150 171Q142 170 140 162L128 26Q126 12 112 11Q98 12 96 26L84 162Q82 170 74 171Q65 170 64 161V27ZM112 179V178V179Z'],
+ paste: [512, 'M160 11Q123 13 105 43H48Q28 44 14 57Q1 71 0 91V411Q1 431 14 445Q28 458 48 459H192V187Q193 153 215 130Q238 108 272 107H320V91Q319 71 306 57Q292 44 272 43H215Q197 13 160 11ZM272 139Q252 140 238 153Q225 167 224 187V459V475Q225 495 238 509Q252 522 272 523H464Q484 522 498 509Q511 495 512 475V255Q512 235 498 221L430 153Q416 139 396 139H320ZM160 51Q182 53 184 75Q182 97 160 99Q138 97 136 75Q138 53 160 51Z'],
+ account: [448, 'M224 267Q259 267 288 250Q317 233 335 203Q352 173 352 139Q352 105 335 75Q317 45 288 28Q259 11 224 11Q189 11 160 28Q131 45 113 75Q96 105 96 139Q96 173 113 203Q131 233 160 250Q189 267 224 267ZM178 315Q103 317 52 367Q2 418 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q446 418 396 367Q345 317 270 315H178Z'],
+ bell: [448, 'M224 11Q210 11 201 20Q192 29 192 43V62Q136 74 101 117Q65 160 64 219V238Q63 310 16 365L8 374Q-5 389 3 408Q12 426 32 427H416Q436 426 445 408Q453 389 440 374L433 365Q385 311 384 238V219Q383 160 347 117Q312 74 256 62V43Q256 29 247 20Q238 11 224 11ZM269 504Q288 485 288 459H224H160Q160 485 179 504Q198 523 224 523Q250 523 269 504Z'],
+ history: [512, 'M75 86 41 52 75 86 41 52Q29 41 15 47Q1 52 0 69V179Q2 201 24 203H134Q151 202 156 188Q162 174 151 162L120 131Q174 77 256 75Q338 77 392 131Q446 185 448 267Q446 349 392 403Q338 457 256 459Q194 458 146 425Q135 417 122 419Q110 422 102 433Q94 444 96 456Q99 469 110 477Q173 522 256 523Q328 522 385 488Q443 454 477 396Q511 339 512 267Q511 195 477 138Q443 80 385 46Q328 12 256 11Q203 11 156 31Q110 51 75 86ZM256 139Q234 141 232 163V267Q232 277 239 284L311 356Q328 370 345 356Q359 339 345 322L280 257V163Q278 141 256 139Z'],
+ theme: [512, 'M512 267Q512 268 512 268Q512 269 512 270Q511 297 490 314Q470 331 442 331H344Q324 332 310 345Q297 359 296 379Q296 384 297 389Q300 403 307 417Q307 418 308 419Q318 439 320 461Q320 485 305 503Q291 521 267 523Q261 523 256 523Q184 522 127 488Q69 454 35 396Q1 339 0 267Q1 195 35 138Q69 80 127 46Q184 12 256 11Q328 12 385 46Q443 80 477 138Q511 195 512 267ZM128 299Q128 285 119 276Q110 267 96 267Q82 267 73 276Q64 285 64 299Q64 313 73 322Q82 331 96 331Q110 331 119 322Q128 313 128 299ZM128 203Q142 203 151 194Q160 185 160 171Q160 157 151 148Q142 139 128 139Q114 139 105 148Q96 157 96 171Q96 185 105 194Q114 203 128 203ZM288 107Q288 93 279 84Q270 75 256 75Q242 75 233 84Q224 93 224 107Q224 121 233 130Q242 139 256 139Q270 139 279 130Q288 121 288 107ZM384 203Q398 203 407 194Q416 185 416 171Q416 157 407 148Q398 139 384 139Q370 139 361 148Q352 157 352 171Q352 185 361 194Q370 203 384 203Z'],
+ check: [448, 'M439 116Q448 126 448 139Q448 152 439 162L183 418Q173 427 160 427Q147 427 137 418L9 290Q0 280 0 267Q0 254 9 244Q19 235 32 235Q45 235 55 244L160 350L393 116Q403 107 416 107Q429 107 439 116Z'],
+ error: [512, 'M256 43Q279 44 291 63L507 431Q517 451 507 471Q495 490 472 491H40Q17 490 5 471Q-5 451 5 431L222 63Q234 44 256 43ZM256 171Q234 173 232 195V307Q234 329 256 331Q278 329 280 307V195Q278 173 256 171ZM288 395Q288 381 279 372Q270 363 256 363Q242 363 233 372Q224 381 224 395Q224 409 233 418Q242 427 256 427Q270 427 279 418Q288 409 288 395Z'],
+ clip: [512, 'M256 203 217 164 256 203 217 164Q224 145 224 123Q223 75 191 44Q160 12 112 11Q64 12 33 44Q1 75 0 123Q1 171 33 202Q64 234 112 235Q134 235 153 228L192 267L153 307Q134 299 112 299Q64 300 33 332Q1 363 0 411Q1 459 33 490Q64 522 112 523Q160 522 191 490Q223 459 224 411Q224 389 217 371L499 88Q510 75 499 62Q477 41 448 41Q419 41 397 62L256 203ZM279 354 397 472 279 354 397 472Q419 493 448 493Q477 493 499 472Q510 459 499 446L343 290L279 354ZM64 123Q65 96 88 81Q112 69 136 81Q159 96 160 123Q159 150 136 165Q112 177 88 165Q65 150 64 123ZM112 363Q139 364 154 387Q166 411 154 435Q139 458 112 459Q85 458 70 435Q58 411 70 387Q85 364 112 363Z'],
+ };
+ // END generated solid glyphs
+ // A glyph drawn as a solid Font Awesome path, fitted to the 24-box the stroke glyphs use.
+ // Names without a solid twin keep their stroke glyph (the brand "V", YouTube, logo, ...).
+ function glyphMarkup(key, def, forSprite) {
+ const solid = SOLID[key];
+ if (!solid) return forSprite ? def.glyph : `<g class="ovi-glyph">${def.glyph}</g>`;
+ const [w, d] = solid; const sc = 18 / 512; const tx = (24 - w * sc) / 2, ty = 3;
+ const paint = forSprite ? ' fill="currentColor" stroke="none" style="fill:var(--ovi-glyph,var(--text-primary,#e6edf7))"' : '';
+ // The position/scale transform must live on a plain (unclassed) <g>, not on the .ovi-glyph
+ // element itself: that class sets transform-box/transform-origin for the hover-scale
+ // animation, and browsers resolve a "transform" *attribute* through the same CSS transform
+ // pipeline — so pairing it with a non-default transform-origin pivots the glyph around
+ // (12,12) instead of applying it in place, throwing every solid glyph off-centre.
+ return `<g class="ovi-glyph ovi-glyph--solid"${paint}><g transform="translate(${tx.toFixed(2)} ${ty}) scale(${sc.toFixed(5)})"><path d="${d}"/></g></g>`;
+ }
+
+ 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 key = resolve(name); const def = registry[key];
+ const off = def.offset || OFFSETS[key] || [0, 0]; // measured optical centring, see scripts/measure-icons.js
+ 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 transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)">${glyphMarkup(key, def, false)}</g></svg>`;
+ }
+
+ // ── Sprite mode (server-rendered lists) ──────────────────────────────────────────────
+ // A page that shows 170 icons should not ship 170 copies of the same ring and glyph. sprite(names) emits each
+ // icon once as a <symbol>; use(name) references it. Colours come from currentColor and inherited custom
+ // properties, so a referenced icon is themed exactly like an inline one. Static by design (no comet, no
+ // progress): pages use it for listings and keep the full inline icon where state or motion matters.
+ function sprite(names) {
+ const seen = new Set();
+ const symbols = (names || []).map(resolve).filter(n => !seen.has(n) && seen.add(n)).map((key) => {
+ const def = registry[key]; const off = def.offset || OFFSETS[key] || [0, 0];
+ return `<symbol id="ovi-${key}" viewBox="0 0 48 48"><circle cx="24" cy="24" r="21" fill="currentColor" opacity=".1"/><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="2.4" opacity=".3"/><g transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--ovi-glyph,var(--text-primary,#e6edf7))">${glyphMarkup(key, def, true)}</g></symbol>`;
+ }).join('');
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">${symbols}</svg>`;
+ }
+ function use(name, size) {
+ const key = resolve(name);
+ return `<span class="ov-icon" data-icon="${key}" data-ovi="${key}" data-fx="none" style="--ovi-size:${parseInt(size, 10) || 32}px" aria-hidden="true"><svg viewBox="0 0 48 48" focusable="false"><use href="#ovi-${key}"/></svg></span>`;
+ }
+
+ 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, offset: Array.isArray(def.offset) ? def.offset : 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, sprite, use, 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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
+/**
+ * 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.
+ *
+ * Motion budget: SVG animations restyle and re-lay-out on the main thread every frame, so an
+ * endlessly moving mark kept idle pages 12-29% busy (browser check, OpenVibe.Host). A mark now
+ * moves only for its intro (the first INTRO_MS of a visit, from the first time a mark is on screen
+ * in this tab; later pages of a multi-page site open with the mark at rest), while it or its link
+ * is hovered or focused (and LINGER_MS after), and while it shows activity (data-state busy/ok,
+ * island.js). Otherwise its CSS animations are paused where they are and its SMIL clock stopped,
+ * so it keeps its look. It never moves while the page is hidden, while it is off-screen
+ * (IntersectionObserver), with data-static, under prefers-reduced-motion or under the network's
+ * own reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js).
+ */
+(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[data-ov]:not([data-ovm-run]) svg,.ov-mark[data-ov]:not([data-ovm-run]) .g,.ov-mark[data-ov]:not([data-ovm-run]) .c,.ov-mark[data-ov]:not([data-ovm-run]) .d,.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .v{animation-play-state:paused!important}
+.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>`;
+ }
+ const INTRO_MS = 8000, LINGER_MS = 1200;
+ const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
+ let io = null, rm = null;
+ // One intro per visit (tab): the deadline is kept in sessionStorage so the next page of a
+ // multi-page site finishes the same intro instead of starting another.
+ const INTRO_KEY = 'ov-mark-intro-end';
+ function introDeadline(now) {
+ let end = 0;
+ try { end = Number(sessionStorage.getItem(INTRO_KEY)) || 0; } catch { /* no storage */ }
+ if (!end || end > now + INTRO_MS) { end = now + INTRO_MS; try { sessionStorage.setItem(INTRO_KEY, String(end)); } catch { /* */ } }
+ return end;
+ }
+ function reduced() {
+ return !!(rm && rm.matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced';
+ }
+ function update(el) {
+ const st = state.get(el); if (!st) return;
+ const svgEl = el.querySelector('svg');
+ if (!el.isConnected || !svgEl) { forget(el); return; }
+ const now = Date.now();
+ const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
+ if (may && !st.introEnd) st.introEnd = introDeadline(now);
+ const s = el.getAttribute('data-state');
+ const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');
+ if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
+ try {
+ if (run) svgEl.unpauseAnimations();
+ else {
+ // Never run yet: rest on the frame where the V's light sweep is centred.
+ if (svgEl.getCurrentTime() < 0.5) svgEl.setCurrentTime(1.6);
+ svgEl.pauseAnimations();
+ }
+ } catch { /* no SMIL */ }
+ clearTimeout(st.timer); st.timer = 0;
+ const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now));
+ if (run && next !== Infinity) st.timer = setTimeout(() => update(el), next - now + 20);
+ }
+ // A mark taken out of the page stops and is let go (a navbar re-render replaces its mark);
+ // mount() picks it up again if it comes back.
+ function forget(el) {
+ const st = state.get(el); if (!st) return;
+ clearTimeout(st.timer); state.delete(el);
+ if (io) try { io.unobserve(el); } catch { /* */ }
+ el.removeAttribute('data-ovm-run');
+ try { el.querySelector('svg').pauseAnimations(); } catch { /* */ }
+ }
+ function updateAll() { document.querySelectorAll('.ov-mark[data-ov]').forEach(update); }
+ const bound = new WeakSet();
+ function track(el) {
+ state.set(el, { introEnd: 0, lingerEnd: 0, hover: false, focus: false, onScreen: io ? null : true, timer: 0 });
+ if (!bound.has(el)) {
+ bound.add(el);
+ const host = el.closest('a,button') || el;
+ const set = (k, v) => () => {
+ const st = state.get(el); if (!st) return;
+ st[k] = v; if (!v) st.lingerEnd = Date.now() + LINGER_MS;
+ update(el);
+ };
+ host.addEventListener('pointerenter', set('hover', true)); host.addEventListener('pointerleave', set('hover', false));
+ host.addEventListener('focusin', set('focus', true)); host.addEventListener('focusout', set('focus', false));
+ }
+ if (io) io.observe(el);
+ update(el);
+ }
+ function mount(root) {
+ (root || document).querySelectorAll('.ov-mark').forEach(el => {
+ if (!el.hasAttribute('data-ov')) {
+ 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) + '_');
+ }
+ if (!state.has(el) && el.isConnected) track(el);
+ });
+ }
+ 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); }
+ try {
+ io = new IntersectionObserver((entries) => entries.forEach((e) => {
+ const st = state.get(e.target); if (!st) return;
+ st.onScreen = e.isIntersecting; update(e.target);
+ }));
+ } catch { io = null; }
+ try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }
+ document.addEventListener('visibilitychange', updateAll);
+ mount();
+ try {
+ new MutationObserver((records) => {
+ let added = false;
+ for (const r of records) {
+ if (r.type === 'attributes') { if (r.target === document.documentElement) updateAll(); else if (state.has(r.target)) update(r.target); continue; }
+ if (r.addedNodes.length) added = true;
+ r.removedNodes.forEach((node) => {
+ if (node.nodeType !== 1) return;
+ if (state.has(node) && !node.isConnected) forget(node);
+ if (node.querySelectorAll) node.querySelectorAll('.ov-mark[data-ov]').forEach((m) => { if (!m.isConnected) forget(m); });
+ });
+ }
+ if (added) mount();
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion'] });
+ } catch { /* */ }
+ }
+ window.ovMarkMount = mount;
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+/**
+ * openvibe-shared/panels.js — one coordinator for every dropdown, drawer and popover on a page.
+ *
+ * Sites keep their own markup and their own open/close toggles. They register each panel here and get the
+ * behaviour that should be identical everywhere:
+ * • one panel open at a time (opening one closes the others)
+ * • Escape and a device rotation close everything
+ * • <body class="ov-panel-open"> while any panel is open (floating buttons step aside)
+ * • a freshly opened panel starts scrolled to the top
+ *
+ * OpenVibePanels.register({ el, openClass: 'show', close: () => …, id?: 'user-menu' })
+ * OpenVibePanels.closeAll(exceptEl?) · OpenVibePanels.anyOpen()
+ *
+ * It watches the panel's class instead of wrapping the site's click handlers, so it cannot fight them: a
+ * panel is "open" exactly when its element has `openClass`, however that came about.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibePanels) return;
+ if (typeof document === 'undefined') { const stub = { register() {}, closeAll() {}, anyOpen: () => false }; if (typeof module !== 'undefined') module.exports = stub; return; }
+
+ const panels = []; // { el, openClass, close, id }
+ const isOpen = (p) => p.el.isConnected && p.el.classList.contains(p.openClass);
+ const anyOpen = () => panels.some(isOpen);
+ const syncBody = () => document.body && document.body.classList.toggle('ov-panel-open', anyOpen());
+
+ function closeAll(except) {
+ // A panel nested in another (a submenu inside the mobile drawer) never closes its container, or vice versa.
+ const related = (a, b) => a && b && (a.contains(b) || b.contains(a));
+ for (const p of panels) if (p.el !== except && !related(p.el, except) && isOpen(p)) { try { p.close(); } catch { p.el.classList.remove(p.openClass); } }
+ syncBody();
+ }
+
+ function register(opts) {
+ const el = opts && opts.el; if (!el || panels.some(p => p.el === el)) return;
+ const p = { el, openClass: opts.openClass || 'open', id: opts.id || '', close: typeof opts.close === 'function' ? opts.close : () => el.classList.remove(opts.openClass || 'open') };
+ panels.push(p);
+ let was = isOpen(p);
+ new MutationObserver(() => {
+ const now = isOpen(p); if (now === was) return; was = now;
+ if (now) { closeAll(el); try { el.scrollTop = 0; } catch { /* */ } }
+ syncBody();
+ }).observe(el, { attributes: true, attributeFilter: ['class'] });
+ syncBody();
+ }
+
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && anyOpen()) closeAll(); });
+ root.addEventListener('orientationchange', () => closeAll(), { passive: true });
+
+ const api = { register, closeAll, anyOpen };
+ root.OpenVibePanels = api;
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+/*
+ * openvibe-shared/release-update.js — plans a new release for an open tab and applies it in place
+ * (ADR-016, Track R). release-watch.js loads it from its own directory when /release.json reports a
+ * release with components or contract ranges; pages never load it themselves. In Node, require() returns
+ * the pure plan(), compatible() and satisfies() (openvibe-shared/release-compat builds on them).
+ *
+ * In place is transactional: every new stylesheet loads and every region is fetched before anything old
+ * is removed; a rejection (style, style-timeout, content, origin) leaves the page as it was.
+ * styles(ids, next) new <link>s for the changed style components' assets, loaded beside the old ones
+ * regions(ids) fresh [data-ov-content="<component>"] HTML (from data-ov-src, default this URL),
+ * without scripts, frames or inline handlers; unchanged data-ov-rev is skipped
+ * commit(list, next, busy, record) replaces the regions busy() allows, returns those still waiting; each
+ * gets ov:content-dispose first (a widget's code drops its listeners and timers) and
+ * ov:content-updated after (it mounts again), both bubbling with { component, release }
+ * apply(plan, next, busy, record) all of the above for an in-place plan
+ */
+(function (root) {
+ 'use strict';
+ const IN_PLACE = { style: 1, content: 1, server: 1 };
+ const ver = (v) => { const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '')); return m ? [+m[1], +m[2], +m[3]] : null; };
+ const cmp = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; return 0; };
+ /** version inside ">=a.b.c <x.y.z", the only form a manifest writes. */
+ function satisfies(version, range) {
+ const v = ver(version); const m = /^>=(\S+) <(\S+)$/.exec(String(range || ''));
+ const lo = m && ver(m[1]); const hi = m && ver(m[2]);
+ return !!(v && lo && hi && cmp(v, lo) >= 0 && cmp(v, hi) < 0);
+ }
+ /** Every contract the page's release produces is still accepted by the server, and the reverse. */
+ function compatible(page, server) {
+ const problems = []; const mine = (page && page.contract_ranges) || {}; const theirs = (server && server.contract_ranges) || {};
+ for (const id of Object.keys(mine)) {
+ const a = mine[id]; const b = theirs[id];
+ if (!a || a.role === 'consumes') continue;
+ if (!b || b.role === 'consumes') problems.push(`${id}: the server no longer serves it`);
+ else if (!satisfies(a.version, b.accepts)) problems.push(`${id}: the server accepts ${b.accepts}, the page speaks ${a.version}`);
+ else if (!satisfies(b.version, a.accepts)) problems.push(`${id}: the page accepts ${a.accepts}, the server speaks ${b.version}`);
+ }
+ return { ok: !problems.length, problems };
+ }
+ /**
+ * What a tab on `page` (the manifest of the release it runs) does about `server`:
+ * { action: none | in-place | prompt | reload, reason?: contract | required | window, changed, problems? }.
+ * In place only when every changed component is style, content (same kind on both sides) or server.
+ */
+ function plan(page, server, now) {
+ if (!server || !page || server.release === page.release) return { action: 'none', changed: [] };
+ const c = compatible(page, server);
+ if (!c.ok) return { action: 'reload', reason: 'contract', changed: [], problems: c.problems };
+ // Manifest 1.2.0: a client generation below the server's minimum reloads (required); a shell change never goes in place.
+ const pg = page.client_generation; const mg = server.min_client_generation;
+ if (Number.isInteger(pg) && Number.isInteger(mg) && pg < mg) return { action: 'reload', reason: 'required', changed: [] };
+ const shellMoved = !!(page.shell || server.shell) && (page.shell && page.shell.version) !== (server.shell && server.shell.version);
+ const a = page.components; const b = server.components; let changed = null;
+ if (a && b) {
+ let ok = true; changed = [];
+ for (const id of Object.keys(Object.assign({}, a, b))) {
+ const x = a[id]; const y = b[id];
+ if (x && y && x.kind === y.kind && x.version === y.version) continue;
+ const kind = (y || x).kind;
+ changed.push({ id, kind });
+ if (!IN_PLACE[kind] || (x && y ? x.kind !== y.kind : kind !== 'server')) ok = false;
+ }
+ if (ok && !shellMoved) return { action: 'in-place', changed };
+ }
+ const windowMs = (Number(server.mixed_version_window_hours) || 0) * 3600e3;
+ if (server.min_client_release === server.release) return { action: 'reload', reason: 'required', changed };
+ if (windowMs > 0 && (now == null ? Date.now() : +now) - Date.parse(server.released_at) > windowMs) return { action: 'reload', reason: 'window', changed };
+ return { action: 'prompt', changed };
+ }
+ if (typeof module !== 'undefined' && module.exports) module.exports = { plan, compatible, satisfies };
+ if (typeof document === 'undefined' || root.OVReleaseUpdate) return;
+
+ const loc = () => root.location.href;
+ const sameOrigin = (u) => { try { return new URL(u, loc()).origin === new URL(loc()).origin; } catch { return false; } };
+
+ function styles(ids, next) {
+ const jobs = [];
+ for (const p of Object.keys(next.assets || {})) {
+ const a = next.assets[p];
+ if (!ids[a.component]) continue;
+ const want = new URL(a.url, loc());
+ for (const l of document.querySelectorAll('link[rel~="stylesheet"]')) {
+ const have = new URL(l.getAttribute('href') || '', loc());
+ if (l.getAttribute('data-ov-asset') !== p && (have.origin !== want.origin || (have.pathname !== want.pathname && have.pathname !== p))) continue;
+ if (have.href === want.href) continue;
+ if (have.origin !== want.origin || !/^https?:$/.test(want.protocol)) return Promise.reject('origin');
+ const n = l.cloneNode(false);
+ n.setAttribute('href', want.href);
+ if (a.integrity) n.setAttribute('integrity', a.integrity); else n.removeAttribute('integrity');
+ jobs.push({ old: l, n });
+ }
+ }
+ return Promise.all(jobs.map((j) => new Promise((ok, no) => {
+ const timer = root.setTimeout(() => no('style-timeout'), 15000);
+ j.n.addEventListener('load', () => { root.clearTimeout(timer); ok(); });
+ j.n.addEventListener('error', () => { root.clearTimeout(timer); no('style'); });
+ j.old.parentNode.insertBefore(j.n, j.old.nextSibling);
+ }))).then(() => jobs, (why) => { jobs.forEach((j) => j.n.remove()); throw why; });
+ }
+
+ async function regions(ids) {
+ const out = []; const docs = {};
+ for (const el of document.querySelectorAll('[data-ov-content]')) {
+ const id = el.getAttribute('data-ov-content');
+ if (!ids[id] || !/^[a-z][a-z0-9-]{0,39}$/.test(id)) continue;
+ const src = new URL(el.getAttribute('data-ov-src') || loc(), loc()); src.hash = '';
+ if (!sameOrigin(src.href)) throw 'origin';
+ if (!docs[src.href]) {
+ const r = await root.fetch(src.href, { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'text/html' } });
+ if (!r.ok) throw 'content';
+ docs[src.href] = new root.DOMParser().parseFromString(await r.text(), 'text/html');
+ }
+ const doc = docs[src.href];
+ const fresh = doc.querySelector(`[data-ov-content="${id}"]`) || (el.hasAttribute('data-ov-src') ? doc.body : null);
+ if (!fresh) throw 'content';
+ for (const x of fresh.querySelectorAll('script, iframe, object, embed, base, frame, meta')) x.remove();
+ for (const x of [fresh, ...fresh.querySelectorAll('*')]) {
+ for (const at of Array.from(x.attributes)) {
+ if (/^on/i.test(at.name) || (/^(href|src|action|formaction|xlink:href|srcdoc)$/i.test(at.name) && /^\s*(javascript|vbscript|data):/i.test(at.value) && !/^\s*data:image\//i.test(at.value))) x.removeAttribute(at.name);
+ }
+ }
+ const rev = fresh.getAttribute('data-ov-rev');
+ if (rev && rev === el.getAttribute('data-ov-rev')) continue;
+ out.push({ el, fresh, id });
+ }
+ return out;
+ }
+
+ function commit(list, next, busy, record) {
+ return list.filter((w) => {
+ if (!w.el.isConnected) return false;
+ const why = busy(w.el);
+ if (why) { record('deferred', why, true); return true; }
+ const r = w.el.getBoundingClientRect();
+ const st = w.el.scrollTop;
+ const ev = (n) => { try { w.el.dispatchEvent(new root.CustomEvent(n, { bubbles: true, detail: { component: w.id, release: next.release } })); } catch { /* */ } };
+ ev('ov:content-dispose');
+ w.el.replaceChildren(...Array.from(w.fresh.childNodes).map((n) => document.importNode(n, true)));
+ const rev = w.fresh.getAttribute('data-ov-rev');
+ if (rev) w.el.setAttribute('data-ov-rev', rev);
+ w.el.scrollTop = st;
+ // A region above the viewport that changed height must not move what the reader is looking at.
+ if (r.bottom <= 0 && root.scrollBy) root.scrollBy(0, w.el.getBoundingClientRect().bottom - r.bottom);
+ ev('ov:content-updated');
+ return false;
+ });
+ }
+
+ /** Applies an in-place plan; resolves with the regions still waiting for commit(), rejects with a reason. */
+ async function apply(p, next, busy, record) {
+ const ids = {}; const k = {};
+ for (const ch of p.changed) { ids[ch.id] = 1; k[ch.kind] = 1; }
+ const list = k.content ? await regions(ids) : [];
+ const jobs = k.style ? await styles(ids, next) : [];
+ jobs.forEach((j) => j.old.remove());
+ if (jobs.length) try { root.dispatchEvent(new root.CustomEvent('ov:styles-updated', { detail: { release: next.release } })); } catch { /* */ }
+ return commit(list, next, busy, record);
+ }
+
+ root.OVReleaseUpdate = { plan, compatible, satisfies, styles, regions, commit, apply };
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,296 @@
@@ -0,0 +1,296 @@
+/*
+ * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R).
+ * Reads /release.json on focus, visibility, reconnect and every 10 min. Changes with components or contract
+ * ranges are planned by release-update.js (loaded on demand from this directory): style, content and server
+ * go in place, anything else prompts. It reloads by itself only when it must (min_client_release or
+ * min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no
+ * focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live
+ * camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).
+ * Release notifications (WS-P task 9): one anonymous EventSource on Events' realtime stream
+ * (host.release.published); a new release of this service runs check(true) after a 0-20 s jitter, at
+ * most every 30 s. Polling stays the fallback.
+ * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta
+ * tag may carry data-service, data-events and data-generation. See README "Releases".
+ */
+(function (root) {
+ if (typeof document === 'undefined' || root.OVRelease) return;
+ const cfg = root.OVReleaseConfig || {};
+ const self = (document.currentScript && document.currentScript.src) || '';
+ const SELF_RE = /release-watch\.js(?:[?#].*)?$/;
+ const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`;
+ const meta = document.querySelector('meta[name="ov-release"]');
+ let current = (meta && meta.content) || null;
+ const gen = meta && meta.hasAttribute('data-generation') ? +meta.getAttribute('data-generation') : undefined;
+ const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json';
+ const IDLE_MS = 2 * 60 * 1000;
+ const MIN_GAP_MS = 60 * 1000;
+ let base = null; // the manifest of the release this page runs
+ let latest = null;
+ let lastCheck = 0;
+ let lastInput = Date.now();
+ let prompted = false;
+ let mustReload = false; let reloadWhy = null;
+ let busyWith = null; // the plan/apply in flight
+ let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;
+ let counts = {}; const totals = {}; const noted = {};
+ let session = null; let lastBeat = 0;
+ try { session = Array.from(root.crypto.getRandomValues(new Uint8Array(8)), (b) => (b | 256).toString(16).slice(1)).join(''); } catch { /* no beats */ }
+
+ ['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));
+
+ // ── Update metrics (D46): counted here, sent on hide, after an update and before a reload ──
+ function record(outcome, reason, once) {
+ const k = `${latest && latest.release}|${outcome}|${reason}`;
+ if (once) { if (noted[k]) return; noted[k] = 1; }
+ for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; }
+ }
+ // beat: send even with nothing counted; ended: the tab is going
+ function flush(beat, ended) {
+ const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url);
+ const any = Object.keys(counts).length > 0;
+ if (!to || (!any && !(beat === true && session && current))) return;
+ try {
+ if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return;
+ const report = { service: latest && latest.service, release: current, to: latest && latest.release };
+ if (any) report.counts = counts;
+ if (session) { report.session = session; lastBeat = Date.now(); if (ended === true) report.ended = true; }
+ const body = JSON.stringify(report);
+ counts = {};
+ const n = root.navigator;
+ if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {});
+ } catch { /* best effort */ }
+ }
+
+ /** Why acting now is not safe, or null. With `scope`, only what is inside that element counts. */
+ function busy(scope) {
+ const d = scope || document;
+ const a = document.activeElement;
+ const typing = a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || ''));
+ if (scope ? a && a !== document.body && scope.contains(a) : typing) return typing ? 'typing' : 'active';
+ if (d.querySelector('form[data-dirty="true"]') || (scope && scope.matches('form[data-dirty="true"]'))) return 'dirty';
+ if (d.querySelector('[data-ov-protected]') || (scope && scope.hasAttribute('data-ov-protected'))) return 'protected';
+ // Someone watching or broadcasting: a playing <video>/<audio>, or a live camera/mic stream.
+ try {
+ for (const m of d.querySelectorAll('video, audio')) {
+ if (!m.paused && !m.ended) return 'media';
+ const so = m.srcObject;
+ if (so && typeof so.getTracks === 'function' && so.getTracks().some((t) => t.readyState === 'live')) return 'capture';
+ }
+ } catch { return 'media'; }
+ if (scope) return null;
+ try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return 'protected'; } catch { return 'protected'; }
+ return !document.hidden && Date.now() - lastInput < IDLE_MS ? 'active' : null;
+ }
+
+ function reload(why) { record('reloaded', why); flush(); root.location.reload(); }
+ function prompt() {
+ if (prompted) return;
+ prompted = true;
+ record('prompted', mustReload ? reloadWhy : 'optional');
+ if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {
+ root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } });
+ }
+ }
+ function maybeReload() {
+ if (!mustReload) return;
+ const why = busy();
+ if (why) record('deferred', why, true); else reload(reloadWhy);
+ }
+ function emit(name, release) { try { root.dispatchEvent(new root.CustomEvent(name, { detail: { release } })); } catch { /* */ } }
+
+ function loadUpdate() {
+ if (root.OVReleaseUpdate) return Promise.resolve(root.OVReleaseUpdate);
+ return lib || (lib = new Promise((ok, no) => {
+ const sc = document.createElement('script');
+ sc.src = updateUrl; sc.async = true;
+ sc.onload = () => (root.OVReleaseUpdate ? ok(root.OVReleaseUpdate) : no('script'));
+ sc.onerror = () => { lib = null; no('script'); };
+ document.head.appendChild(sc);
+ }));
+ }
+ /** Without release-update.js: the release id, min_client_release and the window only. */
+ function legacy(m, pg) {
+ const w = (Number(m.mixed_version_window_hours) || 0) * 3600e3;
+ if (m.min_client_release === m.release || pg < m.min_client_generation) return { action: 'reload', reason: 'required' };
+ return w > 0 && Date.now() - Date.parse(m.released_at) > w ? { action: 'reload', reason: 'window' } : { action: 'prompt' };
+ }
+ function commitRegions(next) {
+ if (waiting.length) waiting = root.OVReleaseUpdate.commit(waiting, next, busy, record);
+ return !waiting.length;
+ }
+ function adopt(next) {
+ base = next; current = next.release; pending = null; mustReload = false;
+ record('applied', kinds || 'server');
+ flush();
+ emit('ov:release-applied', next.release);
+ }
+
+ async function decide(m) {
+ let x = null;
+ if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }
+ const page = base || { release: current, client_generation: gen };
+ let p = x ? x.plan(page, m, Date.now()) : legacy(m, page.client_generation);
+ if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());
+ if (p.action === 'none') return;
+ if (p.action !== 'in-place') {
+ if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; }
+ prompt();
+ maybeReload();
+ return;
+ }
+ try {
+ kinds = Array.from(new Set(p.changed.map((c) => c.kind))).sort().join('+');
+ waiting = await x.apply(p, m, busy, record); pending = m;
+ if (!waiting.length) adopt(m);
+ } catch (why) {
+ waiting = []; pending = null;
+ record('failed', typeof why === 'string' ? why : 'content');
+ failedRelease = m.release;
+ flush();
+ await decide(m);
+ }
+ }
+ function consider(m) {
+ latest = m;
+ if (m.release === current || busyWith || (pending && pending.release === m.release)) return;
+ waiting = []; pending = null; // a newer release supersedes regions still waiting for the last one
+ busyWith = decide(m).catch(() => {}).then(() => { busyWith = null; });
+ }
+
+ async function check(force) {
+ const now = Date.now();
+ if (!force && now - lastCheck < MIN_GAP_MS) return latest;
+ lastCheck = now;
+ let m;
+ try {
+ const r = await root.fetch(url, { cache: 'no-store', credentials: 'omit' });
+ if (!r.ok) return latest;
+ m = await r.json();
+ } catch { return latest; }
+ if (!m || typeof m.release !== 'string') return latest;
+ if (!current) current = m.release;
+ if (!base && m.release === current) base = m;
+ consider(m);
+ live();
+ if (busyWith) await busyWith;
+ return m;
+ }
+
+ // ── Release notifications (host.release.published, public, via OpenVibe.Events) ──
+ // One credential-less EventSource per tab (account switches change nothing). Closed after 5 min hidden,
+ // reopened with last_event_id; errors back off 30 s to 15 min; after 6 failures only polling is left.
+ const TOPIC = 'host.release.published';
+ const HEX = /^[0-9a-f]{7,40}$/;
+ const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastSeq: null };
+ let es = null; let queued = null; let again = false; let seen = [];
+ const t = {}; // timers: run (the jittered check), cool (30 s after it), retry, hide
+ const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); };
+ const same = (a, b) => !!a && !!b && (a === b || (HEX.test(a) && HEX.test(b) && (a.startsWith(b) || b.startsWith(a))));
+ function eventsUrl() {
+ const v = 'eventsUrl' in cfg ? cfg.eventsUrl : meta && meta.hasAttribute('data-events') ? meta.getAttribute('data-events') : undefined;
+ if (v !== undefined) return v && v !== 'off' ? String(v) : null;
+ try { return new URL(root.location.href).protocol === 'https:' ? 'https://events.openvibe.network/realtime/stream' : null; } catch { return null; }
+ }
+ function live() {
+ if (stopped || es || rt.state !== 'off' || typeof root.EventSource !== 'function') return;
+ const svc = cfg.service || (meta && meta.getAttribute('data-service')) || (base && base.service) || (latest && latest.service);
+ const at = eventsUrl();
+ if (!at || !/^[a-z][a-z0-9-]{1,39}$/.test(String(svc || ''))) return;
+ rt.service = svc; rt.url = at;
+ open();
+ if (document.hidden) later('hide', hide, 5 * 60 * 1000);
+ }
+ function open() {
+ let u;
+ try { u = new URL(rt.url, root.location.href); u.searchParams.set('topics', TOPIC); if (rt.lastSeq != null) u.searchParams.set('last_event_id', rt.lastSeq); } catch { rt.state = 'failed'; return; }
+ rt.state = 'connecting';
+ let s;
+ try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; }
+ s.onopen = () => { if (es === s) { rt.state = 'open'; rt.failures = 0; } };
+ s.onmessage = (e) => { if (es === s) heard(e); };
+ s.onerror = () => { if (es === s) fail(); };
+ s.addEventListener('gap', () => { if (es === s) queue(); }); // events were missed: check anyway
+ }
+ function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }
+ function fail() {
+ close();
+ if (rt.state === 'blocked') return;
+ if (++rt.failures >= 6) { rt.state = 'failed'; return; }
+ rt.state = 'backoff';
+ later('retry', () => { if (document.hidden) rt.state = 'hidden'; else open(); }, Math.min(9e5, 3e4 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));
+ }
+ function hide() { if (document.hidden && es) { close(); rt.state = 'hidden'; } }
+ // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.
+ document.addEventListener('securitypolicyviolation', (e) => {
+ try { if (!rt.url || String(e.blockedURI || '').indexOf(new URL(rt.url, root.location.href).origin) !== 0) return; } catch { return; }
+ close(); root.clearTimeout(t.retry); t.retry = null; rt.state = 'blocked';
+ });
+ function heard(e) {
+ let m; try { m = JSON.parse(e.data); } catch { return; }
+ const ev = m && m.event; const p = ev && ev.payload;
+ if (!p || ev.event_type !== TOPIC) return;
+ if (typeof m.seq === 'number') { if (rt.lastSeq != null && m.seq <= rt.lastSeq) return; rt.lastSeq = m.seq; }
+ if (p.service !== rt.service || typeof p.release !== 'string') return;
+ // Ids repeat across origins (Sites' placeholders): a named origin must be ours.
+ if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }
+ rt.events++;
+ if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }
+ seen = seen.concat(ev.event_id).slice(-20);
+ queued = p.release;
+ queue();
+ }
+ /** One check after a 0-20 s jitter; what arrives meanwhile or 30 s after collapses into one more. */
+ function queue() {
+ if (t.run) return;
+ if (t.cool) { again = true; return; }
+ later('run', () => {
+ const q = queued;
+ rt.checks++;
+ later('cool', () => { if (again) { again = false; queue(); } }, 30 * 1000);
+ if (!stopped) check(true).then(() => { if (queued === q) queued = null; });
+ }, Math.floor(Math.random() * 20 * 1000));
+ }
+
+ let stopped = false;
+ root.addEventListener('focus', () => { if (!stopped) check(false); });
+ root.addEventListener('online', () => { if (stopped) return; if (rt.state === 'failed') { rt.state = 'off'; rt.failures = 0; live(); } check(true); });
+ root.addEventListener('pagehide', () => flush(true, true));
+ document.addEventListener('visibilitychange', () => {
+ if (stopped) return;
+ if (document.hidden) { flush(); maybeReload(); if (es) later('hide', hide, 5 * 60 * 1000); return; }
+ root.clearTimeout(t.hide); t.hide = null;
+ if (rt.state === 'hidden') open();
+ check(false);
+ });
+ const tick = root.setInterval(() => {
+ if (stopped) return;
+ if (pending && commitRegions(pending)) adopt(pending);
+ check(false); maybeReload();
+ if (Date.now() - lastBeat >= 3e5) flush(true);
+ }, 30 * 1000);
+ const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);
+ function stop() {
+ stopped = true; root.clearInterval(tick); root.clearInterval(poll);
+ close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); rt.state = 'off';
+ }
+ root.OVRelease = {
+ get current() { return current; },
+ check: () => check(true),
+ state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals, realtime: { ...rt } }),
+ flush,
+ stop,
+ };
+ // The page's own manifest (if still served). No /release.json and no meta tag: left alone.
+ lastCheck = Date.now();
+ root.fetch(url, { cache: 'no-store', credentials: 'omit' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((m) => {
+ if (!m || typeof m.release !== 'string') { if (!current) stop(); return; }
+ if (!current) current = m.release;
+ if (m.release === current) base = m;
+ consider(m);
+ live();
+ flush(true);
+ })
+ .catch(() => { if (!current) stop(); });
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,375 @@
@@ -0,0 +1,375 @@
+/*!
+ * openvibe-shared/shipped — what shipped, on every OpenVibe site, from one feed: the network changelog
+ * OpenVibe.Blog keeps (every deployed commit of every site) and serves through OpenVibe.Network at
+ * GET https://openvibe.network/api/v1/changelog, which every site's CSP already allows.
+ *
+ * Three views, the same on every site:
+ * latest the one-liner "🚀 shipped 5m ago: <newest change>", linking to the site's updates page
+ * list the newest few changes, each linked to its commit, plus the latest "Patch notes" post
+ * log the full updates page: changes grouped by day, "Load more", this site or the whole
+ * network (with a filter per site), and the patch notes posts
+ *
+ * Markup only (the script mounts every element on the page, and the shared footer loads it):
+ * <a data-ov-shipped="latest" data-service="wiki" href="/updates"></a>
+ * <section data-ov-shipped="list" data-service="wiki" data-limit="8" data-more="/updates"></section>
+ * <div data-ov-shipped="log" data-service="wiki"></div> data-service="" = the whole network
+ * <script src="https://openvibe.network/shared/shipped.js" defer></script>
+ *
+ * Or from code: OpenVibeShipped.latest(el, opts), .list(el, opts) (alias mount), .log(el, opts),
+ * .scan(root). Options: service (a registry id; 'auto' = from the hostname; omitted = every site),
+ * limit, title ('' = no heading), href / more (links), api (the feed URL, for tests). DOM nodes only
+ * (commit text is never parsed as HTML), https links only; a failed or empty feed hides the element,
+ * never an error on the page. Styles follow the theme's CSS variables. A classic script, no modules.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ if (typeof window !== 'undefined') {
+ window.OpenVibeShipped = api;
+ if (typeof document !== 'undefined') {
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => api.scan(document));
+ else api.scan(document);
+ }
+ }
+}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+ const API = 'https://openvibe.network/api/v1/changelog';
+ const SITE_NAMES = { live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing', tips: 'Tips', vip: 'VIP', openre: 'OpenRe', sites: 'Sites', realtime: 'Realtime' };
+ const STYLE_ID = 'ov-shipped-style';
+ const TEXT = 'var(--text-primary,var(--text,inherit))';
+ const MUTED = 'var(--text-secondary,var(--text-muted,#8b93ad))';
+ const ACCENT = 'var(--accent-light,var(--accent,#3b82f6))';
+ const BORDER = 'var(--border,rgba(127,127,127,.25))';
+ const CSS = [
+ `.ov-shipped-latest{display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;font-size:.8rem;text-decoration:none;color:${MUTED};background:color-mix(in srgb,var(--bg-secondary,#111827) 70%,transparent);border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);max-width:min(640px,100%);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box}`,
+ `.ov-shipped-latest:hover{border-color:var(--accent,#3b82f6);color:${TEXT}}.ov-shipped-latest svg{flex:none;color:var(--accent,#3b82f6)}`,
+ `.ov-shipped-latest b{color:${TEXT};font-weight:600;overflow:hidden;text-overflow:ellipsis;min-width:0}`,
+ '[data-ov-shipped][hidden],.ov-shipped[hidden],.ov-shipped-latest[hidden]{display:none!important}',
+ '.ov-shipped-home{display:grid;gap:12px;justify-items:start;margin:16px 0;min-width:0}.ov-shipped-home>.ov-shipped{justify-self:stretch}',
+ `.ov-updates{min-width:0}.ov-updates h1{margin:.2em 0 .3em}.ov-updates-lede{color:${MUTED};line-height:1.55;margin:0 0 18px}`,
+ '.ov-shipped{font-size:.92rem;min-width:0}.ov-shipped h3{font-size:1rem;margin:0 0 .5rem;display:flex;gap:.4rem;align-items:center}',
+ '.ov-shipped ul{list-style:none;margin:0;padding:0;display:grid;gap:.35rem}.ov-shipped li{display:flex;gap:.5rem;align-items:baseline;min-width:0}',
+ `.ov-shipped-site{flex:none;font-size:.72rem;font-weight:700;padding:.05rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:${ACCENT};text-decoration:none}`,
+ `.ov-shipped a.ov-shipped-subject{color:${TEXT};text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}`,
+ '.ov-shipped a.ov-shipped-subject:hover{text-decoration:underline}.ov-shipped li time{flex:none;margin-left:auto;font-size:.75rem;opacity:.7}',
+ `.ov-shipped-major{font-weight:600}.ov-shipped-links{display:flex;flex-wrap:wrap;gap:.4rem 1rem;margin-top:.6rem}.ov-shipped-links a{font-weight:600;color:${ACCENT};text-decoration:none}.ov-shipped-links a:hover{text-decoration:underline}`,
+ '.ov-shipped-log{display:flex;flex-direction:column;gap:22px;min-width:0}',
+ '.ov-shipped-bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center}',
+ `.ov-shipped-chip{font:inherit;font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${MUTED};cursor:pointer;text-decoration:none}`,
+ `.ov-shipped-chip:hover{color:${TEXT};border-color:var(--accent,#3b82f6)}.ov-shipped-chip[aria-pressed="true"]{background:var(--accent-strong,#1d4ed8);border-color:var(--accent-strong,#1d4ed8);color:var(--on-accent-strong,#fff)}`,
+ `.ov-shipped-day h3{font-size:.95rem;font-weight:600;color:${MUTED};padding-bottom:8px;border-bottom:1px solid ${BORDER};margin:0 0 4px}`,
+ '.ov-shipped-entries{display:flex;flex-direction:column;gap:2px}',
+ '.ov-shipped-entry{display:flex;align-items:baseline;gap:10px;padding:8px 12px;border-radius:6px;min-width:0}',
+ '.ov-shipped-entry:hover{background:var(--bg-tertiary,rgba(127,127,127,.08))}',
+ `.ov-shipped-hash{font-family:'JetBrains Mono','Fira Code',ui-monospace,monospace;font-size:.8rem;color:${ACCENT};text-decoration:none;flex:none}.ov-shipped-hash:hover{text-decoration:underline}`,
+ `.ov-shipped-text{flex:1;min-width:0;font-size:.9rem;color:${TEXT};overflow-wrap:anywhere}.ov-shipped-text.ov-shipped-major{font-weight:600}`,
+ `.ov-shipped-meta{font-size:.75rem;color:${MUTED};flex:none;white-space:nowrap}`,
+ `.ov-shipped-more{align-self:center;font:inherit;font-size:.85rem;padding:8px 18px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${TEXT};cursor:pointer}.ov-shipped-more:hover{border-color:var(--accent,#3b82f6)}.ov-shipped-more[disabled]{opacity:.6;cursor:default}`,
+ `.ov-shipped-posts{border:1px solid ${BORDER};border-radius:12px;padding:12px 16px}.ov-shipped-posts h3{font-size:.95rem;margin:0 0 8px}.ov-shipped-posts ul{list-style:none;margin:0;padding:0;display:grid;gap:6px}`,
+ `.ov-shipped-posts a{color:${ACCENT};text-decoration:none;font-weight:600}.ov-shipped-posts a:hover{text-decoration:underline}.ov-shipped-posts small{color:${MUTED};margin-left:6px}`,
+ `.ov-shipped-empty{color:${MUTED};text-align:center;padding:24px 0;margin:0}`,
+ '@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}',
+ ].join('');
+ const ROCKET = 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5';
+
+ // ── helpers ─────────────────────────────────────────────────
+ function ago(iso, now) {
+ const t = Date.parse(iso);
+ if (!t) return '';
+ const s = Math.max(0, ((now || Date.now()) - t) / 1000);
+ if (s < 45) return 'just now';
+ if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m ago`;
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
+ if (s < 86400 * 45) return `${Math.round(s / 86400)}d ago`;
+ return `${Math.round(s / (86400 * 30))}mo ago`;
+ }
+ function el(tag, attrs, text) {
+ const n = document.createElement(tag);
+ for (const k of Object.keys(attrs || {})) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
+ if (text != null) n.textContent = text;
+ return n;
+ }
+ function style() {
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return;
+ const s = el('style', { id: STYLE_ID });
+ s.textContent = CSS;
+ (document.head || document.documentElement).appendChild(s);
+ }
+ function safeUrl(u) { return /^https:\/\//.test(String(u || '')) ? String(u) : null; }
+ function localHref(u) { const s = String(u || ''); return /^\/(?!\/)/.test(s) || /^https:\/\//.test(s) ? s : null; }
+ function clamp(n, lo, hi, d) { const v = parseInt(n, 10); return Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : d; }
+ const siteName = (id) => SITE_NAMES[id] || id;
+ function timeEl(iso) { const t = el('time', { datetime: iso, 'data-ov-ago': '' }, ago(iso)); t.title = new Date(iso).toLocaleString(); return t; }
+ function rocket() {
+ const ns = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(ns, 'svg');
+ for (const [k, v] of Object.entries({ width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' })) svg.setAttribute(k, v);
+ const p = document.createElementNS(ns, 'path');
+ p.setAttribute('d', ROCKET);
+ svg.appendChild(p);
+ return svg;
+ }
+
+ /** The registry id for a hostname (or an id already): openvibe.wiki → wiki, pdf.openvibe.tools → tools. */
+ function serviceFor(value) {
+ const v = String(value || '').toLowerCase().trim();
+ if (SITE_NAMES[v]) return v;
+ const h = v.replace(/:\d+$/, '').replace(/^www\./, '');
+ if (/(^|\.)openre\.stream$/.test(h)) return 'openre';
+ if (/(^|\.)openvibe\.tools$/.test(h)) return 'tools';
+ if (/(^|\.)openvibe\.network$/.test(h)) {
+ const sub = h.replace(/\.?openvibe\.network$/, '');
+ return SITE_NAMES[sub] && sub !== 'network' ? sub : 'network';
+ }
+ const m = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);
+ return m && SITE_NAMES[m[1]] ? m[1] : null;
+ }
+ /** 'auto' = this page's host; a site id that is not a registry id (a Tools satellite's 'dev') also falls back to the host; '' = the whole network. */
+ function resolveService(s) {
+ const host = () => (typeof location !== 'undefined' ? serviceFor(location.hostname) : null);
+ if (s === 'auto') return host();
+ return s ? (serviceFor(s) || host()) : null;
+ }
+
+ // One request per feed URL for 30 s, shared by the footer line, the pill and the list on a page.
+ const _feeds = new Map();
+ function feed({ service = null, limit = 5, before = null, api = null } = {}) {
+ const url = new URL(api || API);
+ if (service) url.searchParams.set('service', String(service));
+ url.searchParams.set('limit', String(clamp(limit, 1, 100, 5)));
+ if (before) url.searchParams.set('before', String(before));
+ const key = url.toString();
+ const hit = _feeds.get(key);
+ if (hit && Date.now() - hit.at < 30000) return hit.promise;
+ const promise = fetch(key, { headers: { Accept: 'application/json' }, credentials: 'omit' })
+ .then((r) => (r.ok ? r.json() : null)).catch(() => null);
+ _feeds.set(key, { at: Date.now(), promise });
+ return promise;
+ }
+
+ // "5m ago" stays true while the page is open.
+ let _ticker = null;
+ function tick() {
+ if (_ticker || typeof setInterval === 'undefined' || typeof document === 'undefined') return;
+ _ticker = setInterval(() => {
+ for (const t of document.querySelectorAll('time[data-ov-ago]')) t.textContent = ago(t.getAttribute('datetime'));
+ }, 60000);
+ if (_ticker.unref) _ticker.unref();
+ }
+
+ // ── latest: the one-liner ───────────────────────────────────
+ /** Render the newest entry into `node` (exported for tests). A node that is an <a> keeps its own href. */
+ function renderLatest(node, data, opts) {
+ const o = opts || {};
+ const e = data && Array.isArray(data.entries) ? data.entries[0] : null;
+ node.textContent = '';
+ if (!e || !e.subject) { node.hidden = true; return node; }
+ node.classList.add('ov-shipped-latest');
+ const href = localHref(o.href) || (node.tagName === 'A' && node.getAttribute('href')) || null;
+ if (node.tagName === 'A' && href) node.setAttribute('href', href);
+ node.appendChild(rocket());
+ node.appendChild(document.createTextNode(o.service ? ' shipped ' : ` ${siteName(e.service)} shipped `));
+ node.appendChild(timeEl(e.deployed_at));
+ node.appendChild(document.createTextNode(': '));
+ node.appendChild(el('b', {}, e.subject));
+ node.title = e.subject;
+ node.hidden = false;
+ if (node.style && node.style.display === 'none') node.style.display = '';
+ tick();
+ return node;
+ }
+ async function latest(target, opts) {
+ const o = { ...(opts || {}) };
+ const node = typeof target === 'string' ? document.querySelector(target) : target;
+ if (!node) return null;
+ o.service = resolveService(o.service);
+ style();
+ return renderLatest(node, await feed({ service: o.service, limit: 1, api: o.api }), o);
+ }
+
+ // ── list: the newest few ────────────────────────────────────
+ /** Render a changelog answer into `node` (exported for tests). */
+ function render(node, data, opts) {
+ const o = opts || {};
+ const entries = (data && Array.isArray(data.entries) ? data.entries : []).slice(0, clamp(o.limit, 1, 20, 5));
+ node.textContent = '';
+ if (!entries.length) { node.hidden = true; return node; }
+ node.classList.add('ov-shipped');
+ if (o.title !== '') node.appendChild(el('h3', {}, o.title || 'Recently shipped'));
+ const ul = el('ul');
+ const showSite = o.showSite != null ? o.showSite : !o.service;
+ for (const e of entries) {
+ const li = el('li');
+ if (showSite) li.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));
+ const href = safeUrl(e.url);
+ const a = el(href ? 'a' : 'span', href ? { href, class: `ov-shipped-subject${e.major ? ' ov-shipped-major' : ''}`, rel: 'noopener', target: '_blank', title: e.subject } : { class: 'ov-shipped-subject' }, e.subject);
+ li.appendChild(a);
+ if (e.deployed_at) li.appendChild(timeEl(e.deployed_at));
+ ul.appendChild(li);
+ }
+ node.appendChild(ul);
+ const links = el('div', { class: 'ov-shipped-links' });
+ const more = localHref(o.more);
+ if (more) links.appendChild(el('a', { href: more }, 'All updates →'));
+ const post = data.latest_post && safeUrl(data.latest_post.url) ? data.latest_post : null;
+ if (post) links.appendChild(el('a', { class: 'ov-shipped-notes', href: post.url }, `Patch notes: ${String(post.title || 'latest').replace(/^Patch notes:\s*/, '')} →`));
+ if (links.childNodes.length) node.appendChild(links);
+ node.hidden = false;
+ tick();
+ return node;
+ }
+ async function list(target, opts) {
+ const o = { ...(opts || {}) };
+ const node = typeof target === 'string' ? document.querySelector(target) : target;
+ if (!node) return null;
+ o.service = resolveService(o.service);
+ style();
+ return render(node, await feed({ service: o.service, limit: clamp(o.limit, 1, 20, 5), api: o.api }), o);
+ }
+
+ // ── log: the updates page ───────────────────────────────────
+ function dayOf(iso) { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); }
+ function entryRow(e, showSite) {
+ const row = el('div', { class: 'ov-shipped-entry' });
+ const href = safeUrl(e.url);
+ row.appendChild(el(href ? 'a' : 'span', href ? { class: 'ov-shipped-hash', href, target: '_blank', rel: 'noopener', title: 'The change on GitHub' } : { class: 'ov-shipped-hash' }, e.short || String(e.sha || '').slice(0, 7)));
+ if (showSite) row.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));
+ row.appendChild(el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}` }, e.subject));
+ const meta = el('span', { class: 'ov-shipped-meta' });
+ const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+ meta.textContent = `${e.author ? `${e.author} · ` : ''}${time}`;
+ row.appendChild(meta);
+ return row;
+ }
+ /** Append entries under day headings, continuing the last day already shown (exported for tests). */
+ function appendDays(box, entries, showSite) {
+ let day = box.lastElementChild && box.lastElementChild.classList.contains('ov-shipped-day') ? box.lastElementChild : null;
+ for (const e of entries) {
+ const d = dayOf(e.deployed_at);
+ if (!day || day.getAttribute('data-day') !== d) {
+ day = el('section', { class: 'ov-shipped-day', 'data-day': d });
+ day.appendChild(el('h3', {}, d));
+ day.appendChild(el('div', { class: 'ov-shipped-entries' }));
+ box.appendChild(day);
+ }
+ day.lastElementChild.appendChild(entryRow(e, showSite));
+ }
+ }
+ async function log(target, opts) {
+ const o = { ...(opts || {}) };
+ const node = typeof target === 'string' ? document.querySelector(target) : target;
+ if (!node) return null;
+ style();
+ const own = resolveService(o.service);
+ const pageSize = clamp(o.limit, 10, 100, 50);
+ const params = typeof location !== 'undefined' ? new URLSearchParams(location.search) : new URLSearchParams();
+ // ?site= picks the site on the whole-network page (and "all" on a site's own page).
+ let current = own;
+ const asked = params.get('site');
+ if (asked === 'all') current = null;
+ else if (asked && serviceFor(asked)) current = serviceFor(asked);
+
+ node.textContent = '';
+ node.classList.add('ov-shipped-log');
+ const bar = el('div', { class: 'ov-shipped-bar', role: 'group', 'aria-label': 'Which site' });
+ const posts = el('aside', { class: 'ov-shipped-posts', hidden: '' });
+ const days = el('div', { class: 'ov-shipped-days' });
+ days.style.cssText = 'display:flex;flex-direction:column;gap:24px;min-width:0';
+ const more = el('button', { type: 'button', class: 'ov-shipped-more', hidden: '' }, 'Load more');
+ node.append(bar, posts, days, more);
+ let next = null;
+ let seq = 0;
+
+ function setUrl(svc) {
+ if (o.syncUrl === false || typeof history === 'undefined' || !history.replaceState) return;
+ const u = new URL(location.href);
+ if (own ? svc === own : !svc) u.searchParams.delete('site'); else u.searchParams.set('site', svc || 'all');
+ history.replaceState(history.state, '', u.pathname + u.search + u.hash);
+ }
+ function chip(label, svc) {
+ const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String((svc || null) === (current || null)) }, label);
+ b.addEventListener('click', () => { current = svc || null; setUrl(current); load(true); });
+ return b;
+ }
+ function drawBar(sites) {
+ bar.textContent = '';
+ if (own) { bar.append(chip(siteName(own), own), chip('All of OpenVibe', null)); return; }
+ bar.append(chip('Everything', null));
+ for (const s of (sites || []).slice(0, 30)) bar.appendChild(chip(siteName(s.service), s.service));
+ }
+ function drawPosts(list) {
+ posts.textContent = '';
+ const good = (list || []).filter((p) => safeUrl(p.url)).slice(0, 5);
+ if (!good.length || o.posts === false) { posts.hidden = true; return; }
+ posts.appendChild(el('h3', {}, 'Patch notes'));
+ const ul = el('ul');
+ for (const p of good) {
+ const li = el('li');
+ li.appendChild(el('a', { href: p.url }, p.title || 'Patch notes'));
+ if (p.published_at) li.appendChild(el('small', {}, `${new Date(p.published_at).toLocaleDateString()}${p.entries ? ` · ${p.entries} changes` : ''}`));
+ ul.appendChild(li);
+ }
+ posts.appendChild(ul);
+ posts.hidden = false;
+ }
+ let sitesSeen = null;
+ async function load(reset) {
+ const mine = ++seq;
+ if (reset) {
+ next = null;
+ days.textContent = '';
+ days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Loading…'));
+ for (const b of bar.querySelectorAll('.ov-shipped-chip')) b.setAttribute('aria-pressed', 'false');
+ }
+ more.disabled = true;
+ const data = await feed({ service: current, limit: pageSize, before: reset ? null : next, api: o.api });
+ if (mine !== seq) return;
+ if (reset) {
+ days.textContent = '';
+ if (data && data.sites) sitesSeen = data.sites;
+ if (!own && !sitesSeen) {
+ const all = await feed({ service: null, limit: 1, api: o.api });
+ sitesSeen = all && all.sites;
+ }
+ drawBar(sitesSeen);
+ for (const b of bar.querySelectorAll('.ov-shipped-chip')) if (b.textContent === (current ? siteName(current) : (own ? 'All of OpenVibe' : 'Everything'))) b.setAttribute('aria-pressed', 'true');
+ drawPosts(data && data.posts);
+ }
+ if (!data) {
+ if (reset) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'The update history could not be loaded just now.'));
+ more.hidden = true;
+ return;
+ }
+ const entries = Array.isArray(data.entries) ? data.entries : [];
+ if (reset && !entries.length) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Nothing has shipped here yet.'));
+ appendDays(days, entries, !current);
+ next = data.next || null;
+ more.hidden = !next;
+ more.disabled = false;
+ tick();
+ }
+ more.addEventListener('click', () => load(false));
+ await load(true);
+ return node;
+ }
+
+ // ── markup ──────────────────────────────────────────────────
+ const VIEWS = { latest, list, log };
+ /** Mount every [data-ov-shipped] element under `root` once. */
+ function scan(rootEl) {
+ const r = rootEl || (typeof document !== 'undefined' ? document : null);
+ if (!r || !r.querySelectorAll) return [];
+ const out = [];
+ for (const n of r.querySelectorAll('[data-ov-shipped]')) {
+ if (n.hasAttribute('data-ov-shipped-mounted')) continue;
+ const view = VIEWS[n.getAttribute('data-ov-shipped')];
+ if (!view) continue;
+ n.setAttribute('data-ov-shipped-mounted', '');
+ const d = n.dataset || {};
+ out.push(view(n, { service: d.service || null, limit: d.limit, title: d.title, href: d.href, more: d.more, api: d.api, posts: d.posts === 'off' ? false : undefined }).catch(() => null));
+ }
+ return out;
+ }
+
+ return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, serviceFor, ago, API, SITE_NAMES };
+}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,352 @@
@@ -0,0 +1,352 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — Theme Loader (Browser)
+// Synchronous theme application from cookie/localStorage.
+// Load this BEFORE navbar.js to prevent FOUC.
+// Sets CSS custom properties on <html> from the user's theme.
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ const COOKIE_NAME = 'ov_theme_id';
+ const STORAGE_KEY = 'ov_theme';
+ // The user's theme is owned centrally by openvibe.network, so EVERY app (openvibe.live,
+ // openvibe.tools + subdomains, openvibe.games, openvibe.media) fetches it from there —
+ // that's what makes the theme chosen at openvibe.network/themes sync everywhere.
+ // Apps may override via init({apiBase}).
+ const DEFAULT_API_BASE = 'https://openvibe.network';
+ let _config = { apiBase: DEFAULT_API_BASE };
+
+ // ── Condensed built-in theme variable maps ───────────────
+ // Kept in sync with packages/openvibe-shared/builtin-themes.js
+ // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand
+ const THEMES = {
+ 'vibe': { '--bg-primary':'#0a0f1c','--bg-secondary':'#101828','--bg-tertiary':'#172238','--bg-card':'#131c2e','--bg-hover':'#1c2a44','--bg-input':'#0d1424','--text-primary':'#e6edf7','--text-secondary':'#96a7c2','--text-muted':'#7386a3','--accent':'#3b82f6','--accent-light':'#60a5fa','--accent-dark':'#1d4ed8','--border':'#1f2d47','--border-light':'#2c3d5c','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#3472d8','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'59,130,246','--accent-glow':'rgba(59,130,246,0.35)','--color-scheme':'dark' },
+ 'violet-hour': { '--bg-primary':'#0d0d0f','--bg-secondary':'#16161a','--bg-tertiary':'#1e1e24','--bg-card':'#1a1a20','--bg-hover':'#242430','--bg-input':'#12121a','--text-primary':'#e8e6e3','--text-secondary':'#9a9a9a','--text-muted':'#838282','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#2a2a32','--border-light':'#3a3a44','--live-red':'#e74c3c','--success':'#2ecc71','--warning':'#f39c12','--danger':'#e74c3c','--info':'#3498db','--shadow':'0 2px 12px rgba(0,0,0,0.4)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#8558ec','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'139,92,246','--accent-glow':'rgba(139,92,246,0.35)','--color-scheme':'dark' },
+ 'midnight': { '--bg-primary':'#0a0e17','--bg-secondary':'#111827','--bg-tertiary':'#1a2235','--bg-card':'#151d2e','--bg-hover':'#1e2a42','--bg-input':'#0d1220','--text-primary':'#e2e8f0','--text-secondary':'#94a3b8','--text-muted':'#78879b','--accent':'#6366f1','--accent-light':'#818cf8','--accent-dark':'#4f46e5','--border':'#1e293b','--border-light':'#334155','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#6164ec','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'99,102,241','--accent-glow':'rgba(99,102,241,0.35)','--color-scheme':'dark' },
+ 'forest': { '--bg-primary':'#0a120a','--bg-secondary':'#121e12','--bg-tertiary':'#1a2a1a','--bg-card':'#152015','--bg-hover':'#1e3420','--bg-input':'#0d160d','--text-primary':'#d4e8d4','--text-secondary':'#8aaa8a','--text-muted':'#708e70','--accent':'#4ade80','--accent-light':'#86efac','--accent-dark':'#22c55e','--border':'#1a3a1a','--border-light':'#2a4a2a','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#4ade80','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'74,222,128','--accent-glow':'rgba(74,222,128,0.35)','--color-scheme':'dark' },
+ 'neon-tokyo': { '--bg-primary':'#0a0a0f','--bg-secondary':'#12101a','--bg-tertiary':'#1a1625','--bg-card':'#15111f','--bg-hover':'#221a32','--bg-input':'#0d0b14','--text-primary':'#f0e6ff','--text-secondary':'#b4a0d0','--text-muted':'#87799c','--accent':'#ff006e','--accent-light':'#ff4d94','--accent-dark':'#c9005a','--border':'#2a1a3a','--border-light':'#3a2a4a','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#e60063','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'255,0,110','--accent-glow':'rgba(255,0,110,0.35)','--color-scheme':'dark' },
+ 'dracula': { '--bg-primary':'#282a36','--bg-secondary':'#1e1f29','--bg-tertiary':'#343746','--bg-card':'#2c2e3c','--bg-hover':'#3a3d50','--bg-input':'#21222c','--text-primary':'#f8f8f2','--text-secondary':'#bfbfb2','--text-muted':'#8995b8','--accent':'#bd93f9','--accent-light':'#d4b8ff','--accent-dark':'#9b6ddb','--border':'#44475a','--border-light':'#565970','--live-red':'#ff5555','--success':'#50fa7b','--warning':'#f1fa8c','--danger':'#ff5555','--info':'#8be9fd','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#bd93f9','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'189,147,249','--accent-glow':'rgba(189,147,249,0.35)','--color-scheme':'dark' },
+ 'monokai': { '--bg-primary':'#272822','--bg-secondary':'#1e1f1c','--bg-tertiary':'#3e3d32','--bg-card':'#2d2e27','--bg-hover':'#49483e','--bg-input':'#22231e','--text-primary':'#f8f8f2','--text-secondary':'#cfcfc2','--text-muted':'#979484','--accent':'#a6e22e','--accent-light':'#c4ff50','--accent-dark':'#7dac1c','--border':'#49483e','--border-light':'#5b5a50','--live-red':'#ef4444','--success':'#22c55e','--warning':'#e6db74','--danger':'#f92672','--info':'#66d9ef','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#a6e22e','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'166,226,46','--accent-glow':'rgba(166,226,46,0.35)','--color-scheme':'dark' },
+ 'ocean-deep': { '--bg-primary':'#0a1215','--bg-secondary':'#0f1a1f','--bg-tertiary':'#162228','--bg-card':'#121c22','--bg-hover':'#1a2a32','--bg-input':'#0c1418','--text-primary':'#d0e8ef','--text-secondary':'#7aa8ba','--text-muted':'#658896','--accent':'#00bcd4','--accent-light':'#4dd0e1','--accent-dark':'#0097a7','--border':'#1a3040','--border-light':'#2a4050','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#00bcd4','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'0,188,212','--accent-glow':'rgba(0,188,212,0.35)','--color-scheme':'dark' },
+ 'sunset': { '--bg-primary':'#120a0a','--bg-secondary':'#1a1010','--bg-tertiary':'#241616','--bg-card':'#1e1212','--bg-hover':'#2e1a1a','--bg-input':'#140c0c','--text-primary':'#ffe8e0','--text-secondary':'#c49080','--text-muted':'#977970','--accent':'#ff6b35','--accent-light':'#ff8f5e','--accent-dark':'#d04a1a','--border':'#3a2020','--border-light':'#4a3030','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#ff6b35','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,107,53','--accent-glow':'rgba(255,107,53,0.35)','--color-scheme':'dark' },
+ 'arctic': { '--bg-primary':'#0b1620','--bg-secondary':'#0f1e2b','--bg-tertiary':'#163040','--bg-card':'#122534','--bg-hover':'#1a3a4c','--bg-input':'#0c1a26','--text-primary':'#eaf6ff','--text-secondary':'#9ec3d8','--text-muted':'#7ea3ba','--accent':'#22d3ee','--accent-light':'#a5f3fc','--accent-dark':'#0891b2','--border':'#1e3f52','--border-light':'#2a5268','--live-red':'#ef4444','--success':'#34d399','--warning':'#f59e0b','--danger':'#ef4444','--info':'#7dd3fc','--shadow':'0 2px 12px rgba(2,12,20,0.5)','--shadow-lg':'0 8px 32px rgba(2,12,20,0.65)','--accent-strong':'#22d3ee','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'34,211,238','--accent-glow':'rgba(34,211,238,0.35)','--color-scheme':'dark' },
+ 'ember': { '--bg-primary':'#120808','--bg-secondary':'#1a0e0e','--bg-tertiary':'#241414','--bg-card':'#1e1010','--bg-hover':'#2e1818','--bg-input':'#140a0a','--text-primary':'#f0dada','--text-secondary':'#b0808a','--text-muted':'#95787c','--accent':'#ef4444','--accent-light':'#f87171','--accent-dark':'#b91c1c','--border':'#3a1a20','--border-light':'#4a2a30','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#d73d3d','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'239,68,68','--accent-glow':'rgba(239,68,68,0.35)','--color-scheme':'dark' },
+ 'vapor': { '--bg-primary':'#0e0a14','--bg-secondary':'#14101c','--bg-tertiary':'#1c1628','--bg-card':'#181220','--bg-hover':'#241a34','--bg-input':'#100c16','--text-primary':'#f0e0ff','--text-secondary':'#c090e0','--text-muted':'#8f73b7','--accent':'#e040fb','--accent-light':'#ea80fc','--accent-dark':'#aa00d4','--border':'#2a1a40','--border-light':'#3a2a50','--live-red':'#ef4444','--success':'#69f0ae','--warning':'#f59e0b','--danger':'#ef4444','--info':'#00e5ff','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#bc36d3','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'224,64,251','--accent-glow':'rgba(224,64,251,0.35)','--color-scheme':'dark' },
+ 'slate': { '--bg-primary':'#0f1118','--bg-secondary':'#161820','--bg-tertiary':'#1e2028','--bg-card':'#1a1c24','--bg-hover':'#24262e','--bg-input':'#12141c','--text-primary':'#e4e6ea','--text-secondary':'#a0a4b0','--text-muted':'#828690','--accent':'#a78bfa','--accent-light':'#c4b5fd','--accent-dark':'#7c3aed','--border':'#2a2c38','--border-light':'#3a3c48','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#a78bfa','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'167,139,250','--accent-glow':'rgba(167,139,250,0.35)','--color-scheme':'dark' },
+ 'matrix': { '--bg-primary':'#000000','--bg-secondary':'#050a05','--bg-tertiary':'#0a140a','--bg-card':'#071007','--bg-hover':'#0e1e0e','--bg-input':'#030803','--text-primary':'#00ff41','--text-secondary':'#00b030','--text-muted':'#008f25','--accent':'#00ff41','--accent-light':'#66ff8a','--accent-dark':'#00aa2a','--border':'#0a2a0a','--border-light':'#144014','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#00ff41','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'0,255,65','--accent-glow':'rgba(0,255,65,0.35)','--color-scheme':'dark' },
+ 'nord': { '--bg-primary':'#2e3440','--bg-secondary':'#272c36','--bg-tertiary':'#3b4252','--bg-card':'#333a48','--bg-hover':'#434c5e','--bg-input':'#2a303c','--text-primary':'#eceff4','--text-secondary':'#d8dee9','--text-muted':'#9ba5b8','--accent':'#88c0d0','--accent-light':'#8fbcbb','--accent-dark':'#5e81ac','--border':'#3b4252','--border-light':'#4c566a','--live-red':'#ef4444','--success':'#a3be8c','--warning':'#ebcb8b','--danger':'#bf616a','--info':'#81a1c1','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#88c0d0','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'136,192,208','--accent-glow':'rgba(136,192,208,0.35)','--color-scheme':'dark' },
+ 'gruvbox-dark': { '--bg-primary':'#1d2021','--bg-secondary':'#282828','--bg-tertiary':'#3c3836','--bg-card':'#32302f','--bg-hover':'#504945','--bg-input':'#242424','--text-primary':'#ebdbb2','--text-secondary':'#d5c4a1','--text-muted':'#a69682','--accent':'#fabd2f','--accent-light':'#fce566','--accent-dark':'#d79921','--border':'#3c3836','--border-light':'#504945','--live-red':'#ef4444','--success':'#b8bb26','--warning':'#fe8019','--danger':'#fb4934','--info':'#83a598','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#fabd2f','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'250,189,47','--accent-glow':'rgba(250,189,47,0.35)','--color-scheme':'dark' },
+ 'abyss': { '--bg-primary':'#000000','--bg-secondary':'#080808','--bg-tertiary':'#111111','--bg-card':'#0a0a0a','--bg-hover':'#181818','--bg-input':'#050505','--text-primary':'#cccccc','--text-secondary':'#888888','--text-muted':'#7a7a7a','--accent':'#ffffff','--accent-light':'#ffffff','--accent-dark':'#aaaaaa','--border':'#1a1a1a','--border-light':'#252525','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#ffffff','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,255,255','--accent-glow':'rgba(255,255,255,0.35)','--color-scheme':'dark' },
+ 'copper': { '--bg-primary':'#110d09','--bg-secondary':'#1a140e','--bg-tertiary':'#231c14','--bg-card':'#1e1710','--bg-hover':'#2c2218','--bg-input':'#13100b','--text-primary':'#e8ddd0','--text-secondary':'#b49a80','--text-muted':'#8e7f6a','--accent':'#cd7f32','--accent-light':'#daa060','--accent-dark':'#a06020','--border':'#2a2016','--border-light':'#3a3020','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#a46628','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'205,127,50','--accent-glow':'rgba(205,127,50,0.35)','--color-scheme':'dark' },
+ 'sakura-night': { '--bg-primary':'#0e0a14','--bg-secondary':'#150f1c','--bg-tertiary':'#1c1526','--bg-card':'#181020','--bg-hover':'#221830','--bg-input':'#100c16','--text-primary':'#f0e0f0','--text-secondary':'#c0a0c0','--text-muted':'#927592','--accent':'#f472b6','--accent-light':'#f9a8d4','--accent-dark':'#db2777','--border':'#2a1a30','--border-light':'#3a2a40','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#f472b6','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'244,114,182','--accent-glow':'rgba(244,114,182,0.35)','--color-scheme':'dark' },
+ 'hacker': { '--bg-primary':'#000000','--bg-secondary':'#0a0800','--bg-tertiary':'#141000','--bg-card':'#0e0c00','--bg-hover':'#1c1800','--bg-input':'#060400','--text-primary':'#ffb000','--text-secondary':'#cc8d00','--text-muted':'#a37100','--accent':'#ffb000','--accent-light':'#ffd050','--accent-dark':'#cc8d00','--border':'#1a1400','--border-light':'#2a2200','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#ffb000','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,176,0','--accent-glow':'rgba(255,176,0,0.35)','--color-scheme':'dark' },
+ 'daylight': { '--bg-primary':'#ffffff','--bg-secondary':'#f7f7f8','--bg-tertiary':'#eeeef0','--bg-card':'#ffffff','--bg-hover':'#f0f0f2','--bg-input':'#f5f5f7','--text-primary':'#1a1a2e','--text-secondary':'#4a4a6a','--text-muted':'#747489','--accent':'#8756f3','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#e0e0e6','--border-light':'#d0d0d8','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.08)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.12)','--accent-strong':'#8756f3','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'135,86,243','--accent-glow':'rgba(135,86,243,0.35)','--color-scheme':'light' },
+ 'paper': { '--bg-primary':'#faf8f5','--bg-secondary':'#f0ece6','--bg-tertiary':'#e8e2d8','--bg-card':'#faf8f5','--bg-hover':'#f0ece6','--bg-input':'#f5f2ed','--text-primary':'#2c2416','--text-secondary':'#5a4e3e','--text-muted':'#7b7061','--accent':'#8b6914','--accent-light':'#b08a30','--accent-dark':'#6a5010','--border':'#ddd6c8','--border-light':'#ccc4b0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)','--accent-strong':'#8b6914','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'139,105,20','--accent-glow':'rgba(139,105,20,0.35)','--color-scheme':'light' },
+ 'cloud': { '--bg-primary':'#f0f5ff','--bg-secondary':'#e8eef8','--bg-tertiary':'#dde5f0','--bg-card':'#f5f8ff','--bg-hover':'#e4ecf8','--bg-input':'#edf2fa','--text-primary':'#1a2040','--text-secondary':'#4a5070','--text-muted':'#6b718c','--accent':'#2867ed','--accent-light':'#60a5fa','--accent-dark':'#2563eb','--border':'#d0d8ea','--border-light':'#bcc8e0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,20,60,0.06)','--shadow-lg':'0 8px 32px rgba(0,20,60,0.10)','--accent-strong':'#2867ed','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'40,103,237','--accent-glow':'rgba(40,103,237,0.35)','--color-scheme':'light' },
+ 'meadow': { '--bg-primary':'#f2f8f0','--bg-secondary':'#e8f0e4','--bg-tertiary':'#dce8d6','--bg-card':'#f5faf2','--bg-hover':'#e4f0de','--bg-input':'#edf5ea','--text-primary':'#1a2e16','--text-secondary':'#3a5a32','--text-muted':'#5d7a54','--accent':'#15823e','--accent-light':'#22c55e','--accent-dark':'#15803d','--border':'#c8dcc0','--border-light':'#b0ccaa','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,40,0,0.10)','--accent-strong':'#15823e','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'21,130,62','--accent-glow':'rgba(21,130,62,0.35)','--color-scheme':'light' },
+ 'peach': { '--bg-primary':'#fff5f0','--bg-secondary':'#fbeae2','--bg-tertiary':'#f5ddd2','--bg-card':'#fff7f2','--bg-hover':'#fce8dc','--bg-input':'#fdf0ea','--text-primary':'#2e1a14','--text-secondary':'#6a4438','--text-muted':'#8f6a5c','--accent':'#c04808','--accent-light':'#fb923c','--accent-dark':'#ea580c','--border':'#f0d0c0','--border-light':'#e4bfab','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,20,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,20,0,0.10)','--accent-strong':'#c04808','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'192,72,8','--accent-glow':'rgba(192,72,8,0.35)','--color-scheme':'light' },
+ 'lavender': { '--bg-primary':'#f8f5ff','--bg-secondary':'#f0eaf8','--bg-tertiary':'#e6ddf0','--bg-card':'#faf7ff','--bg-hover':'#eee6f8','--bg-input':'#f4f0fa','--text-primary':'#1e1430','--text-secondary':'#4a3a60','--text-muted':'#796a92','--accent':'#844cf2','--accent-light':'#a78bfa','--accent-dark':'#7c3aed','--border':'#ddd0ee','--border-light':'#ccc0dd','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(40,0,60,0.06)','--shadow-lg':'0 8px 32px rgba(40,0,60,0.10)','--accent-strong':'#844cf2','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'132,76,242','--accent-glow':'rgba(132,76,242,0.35)','--color-scheme':'light' },
+ 'gruvbox-light': { '--bg-primary':'#fbf1c7','--bg-secondary':'#f2e5bc','--bg-tertiary':'#ebdbb2','--bg-card':'#fbf1c7','--bg-hover':'#f2e5bc','--bg-input':'#f5ecc4','--text-primary':'#3c3836','--text-secondary':'#504945','--text-muted':'#766b60','--accent':'#be4707','--accent-light':'#fe8019','--accent-dark':'#af3a03','--border':'#d5c4a1','--border-light':'#bdae93','--live-red':'#ef4444','--success':'#98971a','--warning':'#d79921','--danger':'#cc241d','--info':'#458588','--shadow':'0 2px 12px rgba(40,30,0,0.08)','--shadow-lg':'0 8px 32px rgba(40,30,0,0.12)','--accent-strong':'#be4707','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'190,71,7','--accent-glow':'rgba(190,71,7,0.35)','--color-scheme':'light' },
+ 'snow': { '--bg-primary':'#ffffff','--bg-secondary':'#f8fafc','--bg-tertiary':'#f1f5f9','--bg-card':'#ffffff','--bg-hover':'#f1f5f9','--bg-input':'#f8fafc','--text-primary':'#0f172a','--text-secondary':'#334155','--text-muted':'#69768b','--accent':'#027cbb','--accent-light':'#38bdf8','--accent-dark':'#0284c7','--border':'#e2e8f0','--border-light':'#cbd5e1','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.05)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.08)','--accent-strong':'#027cbb','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'2,124,187','--accent-glow':'rgba(2,124,187,0.35)','--color-scheme':'light' },
+ 'sand': { '--bg-primary':'#f8f4ef','--bg-secondary':'#f0e8de','--bg-tertiary':'#e8ddd0','--bg-card':'#faf6f0','--bg-hover':'#f0e8de','--bg-input':'#f5f0e8','--text-primary':'#2e2418','--text-secondary':'#5a4e3e','--text-muted':'#7a6e5f','--accent':'#8c6707','--accent-light':'#daa520','--accent-dark':'#8b6508','--border':'#ddd0be','--border-light':'#ccc0aa','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)','--accent-strong':'#8c6707','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'140,103,7','--accent-glow':'rgba(140,103,7,0.35)','--color-scheme':'light' },
+ 'rose': { '--bg-primary':'#fff5f7','--bg-secondary':'#fce7ec','--bg-tertiary':'#f5d5de','--bg-card':'#fff7f9','--bg-hover':'#fce0e8','--bg-input':'#fdeef2','--text-primary':'#2e1420','--text-secondary':'#6a3850','--text-muted':'#96617e','--accent':'#de1c47','--accent-light':'#f43f5e','--accent-dark':'#be123c','--border':'#f0c8d4','--border-light':'#e4b0c0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,0,20,0.06)','--shadow-lg':'0 8px 32px rgba(60,0,20,0.10)','--accent-strong':'#de1c47','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'222,28,71','--accent-glow':'rgba(222,28,71,0.35)','--color-scheme':'light' },
+ 'solarized-dark': { '--bg-primary':'#002b36','--bg-secondary':'#073642','--bg-tertiary':'#0a3f4e','--bg-card':'#053542','--bg-hover':'#0d4a58','--bg-input':'#01303d','--text-primary':'#fdf6e3','--text-secondary':'#93a1a1','--text-muted':'#909c9a','--accent':'#b58900','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#0a4050','--border-light':'#105060','--live-red':'#ef4444','--success':'#859900','--warning':'#cb4b16','--danger':'#dc322f','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#947000','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'181,137,0','--accent-glow':'rgba(181,137,0,0.35)','--color-scheme':'dark' },
+ 'solarized-light': { '--bg-primary':'#fdf6e3','--bg-secondary':'#eee8d5','--bg-tertiary':'#e4ddc6','--bg-card':'#fdf6e3','--bg-hover':'#eee8d5','--bg-input':'#f5eedb','--text-primary':'#002b36','--text-secondary':'#586e75','--text-muted':'#5b7478','--accent':'#8d6c00','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#d6ceb5','--border-light':'#c8c0a5','--live-red':'#ef4444','--success':'#859900','--warning':'#cb4b16','--danger':'#dc322f','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,30,40,0.08)','--shadow-lg':'0 8px 32px rgba(0,30,40,0.12)','--accent-strong':'#8d6c00','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'141,108,0','--accent-glow':'rgba(141,108,0,0.35)','--color-scheme':'light' },
+ 'catppuccin-mocha': { '--bg-primary':'#1e1e2e','--bg-secondary':'#181825','--bg-tertiary':'#313244','--bg-card':'#1e1e2e','--bg-hover':'#313244','--bg-input':'#1a1a2c','--text-primary':'#cdd6f4','--text-secondary':'#bac2de','--text-muted':'#81869e','--accent':'#cba6f7','--accent-light':'#dcc0ff','--accent-dark':'#b07de0','--border':'#313244','--border-light':'#45475a','--live-red':'#f38ba8','--success':'#a6e3a1','--warning':'#fab387','--danger':'#f38ba8','--info':'#89b4fa','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#cba6f7','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'203,166,247','--accent-glow':'rgba(203,166,247,0.35)','--color-scheme':'dark' },
+ 'catppuccin-latte': { '--bg-primary':'#eff1f5','--bg-secondary':'#e6e9ef','--bg-tertiary':'#dce0e8','--bg-card':'#eff1f5','--bg-hover':'#dce0e8','--bg-input':'#e8ebf0','--text-primary':'#4c4f69','--text-secondary':'#5c5f77','--text-muted':'#696c83','--accent':'#8839ef','--accent-light':'#a05cff','--accent-dark':'#7028d4','--border':'#ccd0da','--border-light':'#bcc0cc','--live-red':'#d20f39','--success':'#40a02b','--warning':'#fe640b','--danger':'#d20f39','--info':'#1e66f5','--shadow':'0 2px 12px rgba(0,0,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.10)','--accent-strong':'#8839ef','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'136,57,239','--accent-glow':'rgba(136,57,239,0.35)','--color-scheme':'light' },
+ 'high-contrast': { '--bg-primary':'#000000','--bg-secondary':'#0a0a0a','--bg-tertiary':'#1a1a1a','--bg-card':'#0f0f0f','--bg-hover':'#222222','--bg-input':'#0a0a0a','--text-primary':'#ffffff','--text-secondary':'#f0f0f0','--text-muted':'#aaaaaa','--accent':'#ffd700','--accent-light':'#ffed4a','--accent-dark':'#ccac00','--border':'#444444','--border-light':'#666666','--live-red':'#ff0000','--success':'#00ff00','--warning':'#ffff00','--danger':'#ff0000','--info':'#00ffff','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--accent-strong':'#ffd700','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,215,0','--accent-glow':'rgba(255,215,0,0.35)','--color-scheme':'dark' },
+ };
+ // THEMES:END
+
+ function getCookie(name) {
+ const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
+ return match ? decodeURIComponent(match[1]) : null;
+ }
+
+ function setCookie(name, value, days) {
+ const maxAge = days * 24 * 60 * 60;
+ const encoded = encodeURIComponent(value);
+ const host = (typeof location !== 'undefined' && location.hostname) || '';
+ // Cookie policy (CONTRACTS.md): a Domain=.openvibe.tools cookie is set ONLY
+ // when actually running on openvibe.tools or one of its subdomains, so the
+ // theme follows the user across the tool satellites. Everywhere else
+ // (openvibe.network, openvibe.live, openvibe.games, localhost, ...) the
+ // cookie is host-only — NO Domain attribute.
+ if (host && (host.endsWith('.openvibe.tools') || host === 'openvibe.tools')) {
+ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;
+ } else {
+ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
+ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
+ }
+ }
+
+ /**
+ * Apply a theme's CSS variables to document.documentElement.
+ * If themeId is not found in the built-in map, does nothing.
+ */
+ function applyById(themeId) {
+ const vars = THEMES[themeId];
+ if (!vars) return false;
+ applyVars(vars);
+ return true;
+ }
+
+ /**
+ * Apply arbitrary CSS variables object to <html>.
+ */
+ // Every token any built-in theme sets. Applying a theme clears the ones it does not set, so a
+ // theme that names no --success cannot inherit the previous theme's --success on <html>.
+ const KNOWN_PROPS = (function () { const out = {}; for (const id in THEMES) for (const k in THEMES[id]) out[k] = true; return Object.keys(out); })();
+ function applyVars(vars) {
+ if (!vars || typeof vars !== 'object') return;
+ const el = document.documentElement;
+ for (let i = 0; i < KNOWN_PROPS.length; i++) if (!(KNOWN_PROPS[i] in vars)) el.style.removeProperty(KNOWN_PROPS[i]);
+ for (const [prop, val] of Object.entries(vars)) {
+ if (prop.startsWith('--')) el.style.setProperty(prop, val);
+ }
+ // Native controls, scrollbars and form fields follow the theme's light/dark mode.
+ const scheme = vars['--color-scheme'];
+ if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }
+ // The inline critical style paints a fixed dark canvas before any stylesheet; keep it in step with the theme.
+ if (vars['--bg-primary']) el.style.background = vars['--bg-primary'];
+ if (vars['--text-primary']) el.style.color = vars['--text-primary'];
+ syncBrowserUi();
+ }
+
+ // ── The browser's own UI (address bar, theme-color) follows the theme ─────────────────────────────────────────────────
+ // The tab icon and the browser/OS toolbar colour are repainted from the theme that was just
+ // applied, so a violet, light or custom theme gets a matching favicon and address bar. Runs on
+ // every apply: first paint, a change on this page, a change in another tab, a server sync.
+ // favicon: only <link rel="icon" data-ov-icon="<site>"> (written by app-icon.headTags) is touched
+ // event: window 'ov:theme' { accent, accentLight, bg, mode } for anything else that wants to follow
+ const DOTS = { live: '#ef4444', games: '#facc15' };
+ const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;
+ function readColor(name, fallback) {
+ try { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return COLOR_RE.test(v) ? v : fallback; } catch { return fallback; }
+ }
+ function faviconSvg(site, accent, light, bg) {
+ const e = encodeURIComponent;
+ return 'data:image/svg+xml,' + e('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs><linearGradient id="r" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + light + '"/><stop offset="1" stop-color="' + accent + '"/></linearGradient></defs>'
+ + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(1.22)"><circle r="18" fill="none" stroke="url(#r)" stroke-width="4" opacity=".38"/>'
+ + '<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#r)" stroke-width="4.4" stroke-linecap="round"/><path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="' + (document.documentElement.getAttribute('data-theme-mode') === 'light' ? accent : '#fff') + '" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>'
+ + '<circle cx="17.4" cy="4.6" r="3.2" fill="' + (DOTS[site] || light) + '"/></g></svg>');
+ }
+ let _browserUiKey = '';
+ function syncBrowserUi() {
+ if (typeof document === 'undefined' || !document.head) return;
+ const accent = readColor('--accent', '#3b82f6'), light = readColor('--accent-light', accent), bg = readColor('--bg-secondary', readColor('--bg-primary', '#0a0f1c'));
+ const mode = document.documentElement.getAttribute('data-theme-mode') || 'dark';
+ const key = [accent, light, bg, mode].join('|'); if (key === _browserUiKey) return; _browserUiKey = key;
+ try {
+ let meta = document.querySelector('meta[name="theme-color"]');
+ if (!meta) { meta = document.createElement('meta'); meta.name = 'theme-color'; document.head.appendChild(meta); }
+ meta.setAttribute('content', bg);
+ const link = document.querySelector('link[rel~="icon"][data-ov-icon]');
+ if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), accent, light, readColor('--bg-primary', '#0a0f1c')); }
+ } catch { /* cosmetic */ }
+ try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }
+ }
+
+ /**
+ * Resolve the active theme from (in order):
+ * 1. localStorage 'ov_theme' (has id + variables — instant)
+ * 2. Cookie 'ov_theme_id' (just slug — look up in built-in map)
+ * Falls back to 'vibe'.
+ */
+ function resolveAndApply() {
+ // 1. Try localStorage (same-origin cache with full variables)
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const saved = JSON.parse(raw);
+ if (saved && saved.variables && typeof saved.variables === 'object') {
+ applyVars(saved.variables);
+ const cachedId = saved.id || saved.slug || 'vibe';
+ try { document.documentElement.setAttribute('data-theme', cachedId); } catch { /* no DOM */ }
+ return cachedId;
+ }
+ if (saved && (saved.id || saved.slug)) {
+ const id = saved.id || saved.slug;
+ if (applyById(id)) return id;
+ }
+ }
+ } catch { /* corrupt localStorage */ }
+
+ // 2. Try cross-domain cookie
+ const cookieId = getCookie(COOKIE_NAME);
+ if (cookieId && applyById(cookieId)) {
+ // Cache the full variable set so the next load paints from localStorage instantly.
+ cacheLocally(cookieId, THEMES[cookieId]);
+ return cookieId;
+ }
+
+ // 3. Default — vibe (already the CSS fallback values, but apply explicitly)
+ applyById('vibe');
+ return 'vibe';
+ }
+
+ function authToken() {
+ return getCookie('ov_token') ||
+ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token'))) ||
+ null;
+ }
+
+ /**
+ * Write the resolved theme to every local cache. Always stores the FULL variable set —
+ * including for community/custom themes that aren't in the built-in map — so the next
+ * page load can paint the right colours synchronously with no network call and no flash.
+ */
+ function cacheLocally(themeId, vars) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ id: themeId, slug: themeId, variables: vars, at: Date.now(),
+ }));
+ } catch { /* quota */ }
+ setCookie(COOKIE_NAME, themeId, 365);
+ try { document.documentElement.setAttribute('data-theme', themeId); } catch { /* no DOM */ }
+ }
+
+ /**
+ * Save theme choice locally AND to the user's openvibe.network account, so the choice
+ * follows them to every OpenVibe site. Pass opts.custom for per-user variable overrides;
+ * opts.sync === false keeps it device-local.
+ */
+ function save(themeId, variables, opts) {
+ const custom = (opts && opts.custom) || null;
+ const base = variables || THEMES[themeId] || {};
+ const vars = custom ? Object.assign({}, base, custom) : base;
+ if (!vars || !Object.keys(vars).length) return;
+ cacheLocally(themeId, vars);
+ if (opts && opts.sync === false) return;
+ if (typeof fetch === 'undefined') return;
+ const token = authToken();
+ const headers = { 'Content-Type': 'application/json' };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+ fetch(_config.apiBase + '/api/themes/me', {
+ method: 'PUT', headers: headers, credentials: 'include',
+ body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),
+ }).catch(function () { /* offline / signed out — local cache still holds */ });
+ }
+
+ /**
+ * Get the current theme ID (from cookie or localStorage).
+ */
+ function getCurrent() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const saved = JSON.parse(raw);
+ if (saved && (saved.id || saved.slug)) return saved.id || saved.slug;
+ }
+ } catch {}
+ return getCookie(COOKIE_NAME) || 'vibe';
+ }
+
+ // ── Auto-apply on load (synchronous, prevents FOUC) ─────
+ // ── Display preferences: motion + text size, one setting for every OpenVibe site ─────────────
+ // OpenVibeThemeLoader.display.get() → { motion: 'auto'|'reduced', text: '100'|'112'|'125' }
+ // OpenVibeThemeLoader.display.set({ motion, text }) applies here, in other tabs, and saves to the account
+ // Applied as <html data-ov-motion> and --ov-text-scale before first paint; the shared stylesheet
+ // below makes every shared component honour them, and sites can key their own CSS off the same hooks.
+ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'] };
+ function readDisplay() {
+ const out = { motion: 'auto', text: '100' };
+ try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }
+ return out;
+ }
+ function applyDisplay(d) {
+ if (typeof document === 'undefined') return;
+ const el = document.documentElement;
+ if (d.motion === 'reduced') el.setAttribute('data-ov-motion', 'reduced'); else el.removeAttribute('data-ov-motion');
+ if (d.text === '100') { el.style.removeProperty('--ov-text-scale'); el.removeAttribute('data-ov-text'); } else { el.style.setProperty('--ov-text-scale', String(parseInt(d.text, 10) / 100)); el.setAttribute('data-ov-text', d.text); }
+ if (!document.getElementById('ov-display-css') && document.head) {
+ const st = document.createElement('style'); st.id = 'ov-display-css';
+ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-mark .o,html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}';
+ document.head.appendChild(st);
+ }
+ try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
+ }
+ function setDisplay(patch, opts) {
+ const d = readDisplay();
+ for (const k in DISPLAY_OPTS) if (patch && DISPLAY_OPTS[k].indexOf(String(patch[k])) >= 0) d[k] = String(patch[k]);
+ try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(d)); } catch (e) { /* */ }
+ applyDisplay(d);
+ if (!(opts && opts.local) && typeof fetch !== 'undefined') {
+ const token = authToken(); const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;
+ if (token || /(?:^|; )ov_sso_hint=account/.test(document.cookie)) fetch(_config.apiBase + '/api/themes/me/display', { method: 'PUT', headers: headers, credentials: 'include', body: JSON.stringify(d) }).catch(function () {});
+ }
+ return d;
+ }
+ applyDisplay(readDisplay());
+
+ const activeId = resolveAndApply();
+ // The icon link may be parsed after this script: repaint once the head is complete.
+ if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { _browserUiKey = ''; syncBrowserUi(); });
+ // A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.
+ if (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) applyDisplay(readDisplay()); });
+
+ // Look up a theme we don't ship in the built-in map (community/custom themes) from the
+ // central catalog. Without this, any non-built-in id fell through to the default 'vibe'
+ // — which is why a custom theme could "disappear" and flash purple on every load.
+ function fetchThemeVars(themeId) {
+ return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) { return (d && d.theme && d.theme.variables) || null; })
+ .catch(function () { return null; });
+ }
+
+ // ── Pull the account's theme from openvibe.network ───────
+ // Runs IMMEDIATELY, not on a 500ms timer: the cached theme is already painted, so this is
+ // a silent correction — delaying it only widened the window where a cold or stale cache
+ // showed the wrong theme. There is also no token gate any more; the ov_token cookie rides
+ // along via credentials:'include' (openvibe.network allows credentialed CORS per origin),
+ // so this now works on sites where the cookie isn't readable from JS — previously the sync
+ // silently never ran there and the user's theme appeared to be "lost".
+ function syncFromServer() {
+ if (typeof fetch === 'undefined') return;
+ const token = authToken();
+ const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
+ fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (data) {
+ if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text }) !== JSON.stringify(cur)) setDisplay(data.display, { local: true }); }
+ if (!data || !data.theme_id) return;
+ const serverId = data.theme_id;
+ const custom = (data.custom_variables && typeof data.custom_variables === 'object'
+ && Object.keys(data.custom_variables).length) ? data.custom_variables : null;
+ const builtin = THEMES[serverId];
+ const settle = function (base) {
+ const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
+ if (!vars || !Object.keys(vars).length) return;
+ applyVars(vars);
+ cacheLocally(serverId, vars); // cacheLocally, not save() — no write-back loop
+ };
+ if (builtin) return settle(builtin);
+ fetchThemeVars(serverId).then(settle);
+ })
+ .catch(function () { /* offline / signed out — keep the cached theme */ });
+ }
+ syncFromServer();
+
+ // ── Public API ───────────────────────────────────────────
+ const OpenVibeThemeLoader = {
+ THEMES: THEMES,
+ display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS },
+ apply: applyById,
+ applyVars: applyVars,
+ save: save,
+ getCurrent: getCurrent,
+ resolveAndApply: resolveAndApply,
+ init: function (opts) {
+ if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {
+ _config.apiBase = opts.apiBase.trim();
+ }
+ return OpenVibeThemeLoader;
+ },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeThemeLoader;
+ else root.OpenVibeThemeLoader = OpenVibeThemeLoader;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,198 @@
@@ -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,#60a5fa)) 45%, var(--border,rgba(255,255,255,.1)));
+ box-shadow:0 20px 50px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.04),0 0 30px color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 22%, transparent);
+ color:var(--text-secondary,#b6bdd2);font-size:.83rem;line-height:1.55;pointer-events:none;opacity:0;
+ transform:translateY(6px) scale(.97);transition:opacity .18s ease,transform .22s cubic-bezier(.2,1.3,.3,1);
+ overflow:hidden}
+.ovtip.is-on{opacity:1;transform:none}
+.ovtip.is-interactive{pointer-events:auto}
+.ovtip::before{content:'';position:absolute;inset:0;pointer-events:none;
+ background:radial-gradient(120% 90% at 0% 0%, color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 16%, transparent), transparent 60%)}
+/* A light sweep on open — draws the eye without moving anything around it. */
+.ovtip::after{content:'';position:absolute;top:0;bottom:0;left:-60%;width:45%;pointer-events:none;
+ background:linear-gradient(100deg,transparent,color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 26%, transparent),transparent)}
+.ovtip.is-on::after{animation:ovtipSweep .9s cubic-bezier(.3,.7,.3,1) .1s 1}
+@keyframes ovtipSweep{to{transform:translateX(420%)}}
+.ovtip-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;position:relative}
+.ovtip-ico{width:26px;height:26px;border-radius:8px;display:grid;place-items:center;flex:none;font-size:.78rem;color:#fff;
+ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#60a5fa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 55%,#000));
+ box-shadow:0 4px 12px color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 45%, transparent)}
+.ovtip-title{font-size:.9rem;font-weight:800;color:var(--text-primary,#f1f4fb);letter-spacing:-.01em}
+.ovtip-body{position:relative}
+.ovtip-body b,.ovtip-body strong{color:var(--text-primary,#f1f4fb);font-weight:700}
+.ovtip-body em{color:var(--ovtip-accent,var(--accent,#60a5fa));font-style:normal;font-weight:600}
+.ovtip-body code{background:var(--bg-tertiary,#1a1b28);padding:1px 6px;border-radius:5px;font-size:.78rem}
+.ovtip-foot{margin-top:8px;padding-top:8px;border-top:1px dashed var(--border,rgba(255,255,255,.1));
+ font-size:.74rem;color:var(--text-muted,#8b93ad);display:flex;align-items:center;gap:6px;position:relative}
+.ovtip-arrow{position:fixed;z-index:30001;width:10px;height:10px;transform:rotate(45deg);opacity:0;transition:opacity .18s;
+ background:var(--bg-card,#171826);border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#60a5fa)) 45%, var(--border,rgba(255,255,255,.1)))}
+.ovtip-arrow.is-on{opacity:1}
+.ovtip-arrow.from-top{border-top:0;border-left:0}
+.ovtip-arrow.from-bottom{border-bottom:0;border-right:0}
+.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,#60a5fa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 60%,#000));
+ box-shadow:0 6px 16px color-mix(in srgb,var(--ovtip-accent,var(--accent,#60a5fa)) 40%,transparent)}
+.ovtip-dismiss{flex:0 0 auto;color:var(--text-muted,#8b93ad);background:none;border:1px solid var(--border,rgba(255,255,255,.12))}
+.ovtip-go:active,.ovtip-dismiss:active{transform:scale(.975)}
+.ovtip-go i{font-size:.72rem}
+/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -0,0 +1,175 @@
+/**
+ * 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: '…' })
+ * OpenVibeUI.notice({ id: 'yt-upstream', type: 'warning', title: '…', message: '…', links: [{ label, href }], dismissible: true })
+ * a page-level notice that always sits directly BELOW the site's navbar (never above it), one
+ * design for service status, maintenance, read-only mode and similar. Returns { close, update }.
+ *
+ * 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() {} }; }, notice() { return { close() {}, update() {} }; }, 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}
+.ovui-notices{display:flex;flex-direction:column;gap:8px;max-width:1080px;margin:14px auto 0;padding:0 16px;box-sizing:border-box;width:100%}
+.ovui-notices:empty{display:none}
+.ovui-notice{--ovui-c:var(--accent,#3b82f6);display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:start;padding:12px 14px;border-radius:14px;border:1px solid color-mix(in srgb,var(--ovui-c) 40%,transparent);background:color-mix(in srgb,var(--ovui-c) 9%,var(--bg-secondary,#111826));color:var(--text-primary,#e6edf7);font:400 14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1)}
+.ovui-notice[data-type=success]{--ovui-c:var(--success,#22c55e)}.ovui-notice[data-type=error]{--ovui-c:var(--danger,#ef4444)}.ovui-notice[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}
+.ovui-notice-ic{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--ovui-c);color:#0b1220;font:800 13px/1 system-ui,sans-serif;margin-top:1px}
+.ovui-notice b{font-weight:700}.ovui-notice-msg{color:var(--text-secondary,#a8b3c4)}
+.ovui-notice-links{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:6px}.ovui-notice-links a{color:var(--accent-light,var(--accent,#60a5fa));font-weight:600;text-decoration:none}.ovui-notice-links a:hover{text-decoration:underline}
+@media (max-width:560px){.ovui-notices{margin-top:10px;padding:0 12px}}
+@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();
+ });
+ }
+
+ // ── Page notices ─────────────────────────────────────────
+ const notices = new Map();
+ let noticeHost = null;
+ function placeNoticeHost() {
+ if (!noticeHost) { noticeHost = el('div', 'ovui-notices'); noticeHost.setAttribute('aria-live', 'polite'); }
+ // Directly after the navbar, whichever flavour the site uses; otherwise at the top of the content.
+ const nav = document.querySelector('nav.openvibe-navbar, #navbar-mount, .navbar, header[role="banner"]');
+ const anchor = nav ? (nav.id === 'navbar-mount' || nav.parentNode === document.body ? nav : nav) : null;
+ if (anchor && anchor.parentNode) { if (anchor.nextSibling !== noticeHost) anchor.parentNode.insertBefore(noticeHost, anchor.nextSibling); return true; }
+ const main = document.querySelector('main') || document.body.firstElementChild;
+ if (!noticeHost.isConnected && main && main.parentNode) main.parentNode.insertBefore(noticeHost, main);
+ return false;
+ }
+ function notice(o) {
+ o = o || {}; css();
+ const id = String(o.id || 'n' + Date.now());
+ try { if (o.dismissible !== false && sessionStorage.getItem('ovui_notice_' + id) === '1') return { close() {}, update() {} }; } catch { /* */ }
+ if (notices.has(id)) notices.get(id).close(true);
+ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';
+ const n = el('div', 'ovui-notice'); n.dataset.type = type; n.setAttribute('role', type === 'error' ? 'alert' : 'status');
+ n.appendChild(el('span', 'ovui-notice-ic', type === 'success' ? '✓' : type === 'info' ? 'i' : '!'));
+ const body = el('div');
+ const fill = (p) => {
+ body.replaceChildren();
+ const line = el('div'); if (p.title) { line.appendChild(el('b', null, p.title)); line.appendChild(document.createTextNode(' ')); }
+ if (p.message) line.appendChild(el('span', 'ovui-notice-msg', p.message)); body.appendChild(line);
+ const links = (p.links || []).map((l) => { const h = l && l.label && safeHref(l.href); if (!h) return null; const a = el('a', null, l.label); a.href = h; return a; }).filter(Boolean);
+ if (links.length) { const row = el('div', 'ovui-notice-links'); links.forEach((a) => row.appendChild(a)); body.appendChild(row); }
+ };
+ fill(o); n.appendChild(body);
+ const close = (silent) => { n.remove(); notices.delete(id); if (!silent && o.dismissible !== false) { try { sessionStorage.setItem('ovui_notice_' + id, '1'); } catch { /* */ } } };
+ if (o.dismissible !== false) { const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close(false)); n.appendChild(x); }
+ // The navbar may mount after us: keep trying briefly until the notice sits under it.
+ let tries = 0; const settle = () => { const ok = placeNoticeHost(); if (!ok && ++tries < 40) setTimeout(settle, 150); }; settle();
+ noticeHost.appendChild(n);
+ const handle = { close: () => close(true), update: (p) => fill(Object.assign({}, o, p)), el: n };
+ notices.set(id, handle);
+ return handle;
+ }
+
+ const api = {
+ toast, notice,
+ 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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — User Card & Context Menu
+// Right-click any username to see profile card, quick actions,
+// linked services, name effects, and moderator tools.
+// Usage: OpenVibeUserCard.init({ apiBase, token, currentUserId })
+// OpenVibeUserCard.attach(element, userId)
+// ═══════════════════════════════════════════════════════════════
+
+(function (root) {
+ 'use strict';
+
+ let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };
+ let _cardEl = null;
+ let _cache = new Map();
+
+ function injectStyles() {
+ if (document.getElementById('openvibe-usercard-styles')) return;
+ const s = document.createElement('style');
+ s.id = 'openvibe-usercard-styles';
+ s.textContent = `
+ @keyframes openvibe-card-in { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
+ .openvibe-usercard-overlay { position: fixed; inset: 0; z-index: 99998; }
+ .openvibe-usercard {
+ position: fixed; z-index: 99999;
+ width: 300px; background: var(--bg-card, #22222c);
+ border: 1px solid var(--border, #333340); border-radius: 12px;
+ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ color: var(--text-primary, #e0e0e0);
+ animation: openvibe-card-in .2s ease; overflow: hidden;
+ }
+ .openvibe-usercard-banner {
+ height: 60px; background: linear-gradient(135deg, var(--accent-dark, #a07840), var(--accent, #3b82f6));
+ position: relative;
+ }
+ .openvibe-usercard-avatar {
+ width: 56px; height: 56px; border-radius: 50%;
+ border: 3px solid var(--bg-card, #22222c);
+ position: absolute; bottom: -28px; left: 16px;
+ background: var(--bg-tertiary, #2a2a38);
+ object-fit: cover;
+ }
+ .openvibe-usercard-body { padding: 36px 16px 12px; }
+ .openvibe-usercard-name {
+ font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 6px;
+ }
+ .openvibe-usercard-name .role-badge {
+ font-size: 9px; padding: 2px 6px; border-radius: 3px;
+ font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
+ }
+ .openvibe-usercard-name .role-admin { background: rgba(231,76,60,0.15); color: #e74c3c; }
+ .openvibe-usercard-name .role-global_mod { background: rgba(46,204,113,0.15); color: #2ecc71; }
+ .openvibe-usercard-name .role-streamer { background: rgba(155,89,182,0.15); color: #9b59b6; }
+ .openvibe-usercard-username { font-size: 12px; color: var(--text-muted, #707080); margin-bottom: 8px; }
+ .openvibe-usercard-bio { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; margin-bottom: 10px; max-height: 40px; overflow: hidden; }
+ .openvibe-usercard-services { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
+ .openvibe-usercard-services .svc {
+ font-size: 10px; padding: 3px 8px; border-radius: 4px;
+ background: var(--bg-tertiary, #2a2a38); color: var(--text-secondary, #b0b0b8);
+ display: flex; align-items: center; gap: 4px;
+ }
+ .openvibe-usercard-services .svc .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success, #2ecc71); }
+ .openvibe-usercard-divider { height: 1px; background: var(--border, #333340); margin: 8px -16px; }
+ .openvibe-usercard-actions { display: flex; flex-direction: column; gap: 2px; }
+ .openvibe-usercard-actions button {
+ display: flex; align-items: center; gap: 8px;
+ width: 100%; padding: 8px 12px; background: none;
+ border: none; border-radius: 6px; color: var(--text-primary, #e0e0e0);
+ font-size: 12px; cursor: pointer; text-align: left;
+ transition: background .12s;
+ }
+ .openvibe-usercard-actions button:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-usercard-actions button.danger { color: var(--live-red, #e74c3c); }
+ .openvibe-usercard-actions button .icon { font-size: 14px; width: 20px; text-align: center; }
+
+ /* Name effects (CSS classes applied to usernames) */
+ .openvibe-name-fx-rainbow { background: linear-gradient(90deg, #ff0000, #ff7700, #ffff00, #00ff00, #0000ff, #8b00ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; background-size: 200%; animation: openvibe-rainbow 3s linear infinite; }
+ @keyframes openvibe-rainbow { to { background-position: 200% center; } }
+ .openvibe-name-fx-glow { text-shadow: 0 0 8px currentColor, 0 0 16px currentColor; }
+ .openvibe-name-fx-fire { text-shadow: 0 0 4px #ff6600, 0 -2px 8px #ff3300, 0 -4px 12px #cc0000; }
+ .openvibe-name-fx-ice { text-shadow: 0 0 6px #00ccff, 0 0 12px #0088ff; color: #aaddff; }
+ .openvibe-name-fx-gold { background: linear-gradient(180deg, #ffd700, #daa520, #ffd700); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: none; }
+ .openvibe-name-fx-glitch { animation: openvibe-glitch .1s infinite; }
+ @keyframes openvibe-glitch { 0%,100%{transform:none} 25%{transform:translate(-1px,1px)} 50%{transform:translate(1px,-1px)} 75%{transform:translate(-1px,-1px)} }
+ .openvibe-name-fx-neon { color: #39ff14; text-shadow: 0 0 5px #39ff14, 0 0 10px #39ff14, 0 0 20px #39ff14; }
+ .openvibe-name-fx-shadow { text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }
+ `;
+ document.head.appendChild(s);
+ }
+
+ // ── Fetch User Data ──────────────────────────────────────
+ async function fetchUser(userId) {
+ if (_cache.has(userId)) return _cache.get(userId);
+ try {
+ const headers = {};
+ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
+ const res = await fetch(`${_config.apiBase}/api/users/${userId}/card`, { headers, credentials: 'include' });
+ if (!res.ok) return null;
+ const data = await res.json();
+ _cache.set(userId, data);
+ setTimeout(() => _cache.delete(userId), 120_000); // 2min cache
+ return data;
+ } catch { return null; }
+ }
+
+ // ── Render Card ──────────────────────────────────────────
+ function renderCard(data, x, y) {
+ closeCard();
+ const user = data.user;
+ const linked = data.linked_accounts || [];
+
+ const roleBadge = user.role !== 'user'
+ ? `<span class="role-badge role-${user.role}">${user.role.replace('_', ' ')}</span>` : '';
+
+ const nameClass = data.name_effect ? `openvibe-name-fx-${data.name_effect}` : '';
+ const nameColor = user.profile_color ? `color:${user.profile_color}` : '';
+
+ const services = linked.map(l => {
+ const icons = { openvibelive: '📡', openvibegames: '⚔️', openvibemaps: '🗺️' };
+ return `<span class="svc"><span class="dot"></span>${icons[l.service] || '🔗'} ${l.service_username || l.service}</span>`;
+ }).join('');
+
+ const isSelf = _config.currentUserId && String(_config.currentUserId) === String(user.id);
+ const isMod = ['admin', 'global_mod'].includes(_config.currentRole);
+
+ let actions = '';
+ if (!isSelf) {
+ actions += `<button data-act="profile"><span class="icon">👤</span> View Profile</button>`;
+ actions += `<button data-act="message"><span class="icon">💬</span> Send Message</button>`;
+ actions += `<button data-act="follow"><span class="icon">➕</span> Follow</button>`;
+ if (isMod) {
+ actions += `<div class="openvibe-usercard-divider"></div>`;
+ actions += `<button data-act="warn"><span class="icon">⚠️</span> Warn User</button>`;
+ actions += `<button data-act="mute"><span class="icon">🔇</span> Mute</button>`;
+ actions += `<button data-act="ban" class="danger"><span class="icon">🚫</span> Ban User</button>`;
+ }
+ } else {
+ actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;
+ actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;
+ }
+
+ const card = document.createElement('div');
+ card.className = 'openvibe-usercard';
+
+ const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;
+ card.innerHTML = `
+ <div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>
+ <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" onerror="this.style.display='none'">
+ <div class="openvibe-usercard-body">
+ <div class="openvibe-usercard-name">
+ <span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>
+ ${roleBadge}
+ </div>
+ <div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>
+ ${user.bio ? `<div class="openvibe-usercard-bio">${user.bio}</div>` : ''}
+ ${services ? `<div class="openvibe-usercard-services">${services}</div>` : ''}
+ <div class="openvibe-usercard-divider"></div>
+ <div class="openvibe-usercard-actions">${actions}</div>
+ </div>
+ `;
+
+ // Position
+ const vw = window.innerWidth, vh = window.innerHeight;
+ card.style.left = Math.min(x, vw - 320) + 'px';
+ card.style.top = Math.min(y, vh - 400) + 'px';
+
+ // Overlay to close on click-outside
+ const overlay = document.createElement('div');
+ overlay.className = 'openvibe-usercard-overlay';
+ overlay.addEventListener('click', closeCard);
+ overlay.addEventListener('contextmenu', e => { e.preventDefault(); closeCard(); });
+
+ document.body.appendChild(overlay);
+ document.body.appendChild(card);
+ _cardEl = { card, overlay };
+
+ // Action handlers
+ card.querySelectorAll('[data-act]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const act = btn.dataset.act;
+ closeCard();
+ if (act === 'profile') {
+ const url = isSelf ? `${_config.apiBase}/my`
+ : `${_config.apiBase}/user/${user.username}`;
+ window.open(url, '_blank');
+ } else if (act === 'message') {
+ // Dispatch custom event for the host app to handle
+ document.dispatchEvent(new CustomEvent('openvibe-user-action', {
+ detail: { action: act, user }
+ }));
+ } else {
+ document.dispatchEvent(new CustomEvent('openvibe-user-action', {
+ detail: { action: act, user }
+ }));
+ }
+ });
+ });
+ }
+
+ function closeCard() {
+ if (_cardEl) {
+ _cardEl.card.remove();
+ _cardEl.overlay.remove();
+ _cardEl = null;
+ }
+ }
+
+ // ── Public API ───────────────────────────────────────────
+ const OpenVibeUserCard = {
+ init(opts = {}) {
+ Object.assign(_config, opts);
+ injectStyles();
+ },
+
+ /** Attach right-click card to an element. */
+ attach(el, userId) {
+ el.style.cursor = 'pointer';
+ el.addEventListener('contextmenu', async e => {
+ e.preventDefault();
+ const data = await fetchUser(userId);
+ if (data) renderCard(data, e.clientX, e.clientY);
+ });
+ // Also support click on mobile
+ el.addEventListener('click', async e => {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ const data = await fetchUser(userId);
+ if (data) renderCard(data, e.clientX, e.clientY);
+ }
+ });
+ },
+
+ /** Programmatically show a card at position. */
+ async show(userId, x, y) {
+ const data = await fetchUser(userId);
+ if (data) renderCard(data, x, y);
+ },
+
+ close: closeCard,
+ clearCache() { _cache.clear(); },
+
+ /** Apply name effect CSS class to an element. */
+ applyNameEffect(el, effectName) {
+ if (!effectName) return;
+ el.classList.add(`openvibe-name-fx-${effectName}`);
+ },
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;
+ else root.OpenVibeUserCard = OpenVibeUserCard;
+
+})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
new file mode 100644
new file mode 100644
@@ -0,0 +1,447 @@
@@ -0,0 +1,447 @@
+/*
+ * openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P task 6), taken out of
+ * OpenVibe.Live's public/js/ov-loader.js so every OpenVibe site loads its code the same way.
+ *
+ * const rt = OVWebRuntime.create({ features, routes, versions, ... }); // registry: see README "Web runtime"
+ * rt.load('broadcast') → the feature's markup fragment, its dependencies, stylesheets and scripts (in order), then
+ * its `after` hook; once, cached; a failed load can be retried.
+ * rt.route(path) → every feature the path's routes name, as one promise.
+ * rt.prefetch('channel') → download only (link rel=prefetch), never execute; skipped on Save-Data, 2g and < 2 GB
+ * of memory, and within a budget (count, and bytes where sizes are known).
+ * rt.nextRoute() / rt.gen() / rt.isCurrent(gen)
+ * → route generations: a loader that awaited something can tell whether the visitor moved on.
+ * rt.scope() → timers, listeners, observers, fetches (AbortSignal) and child scopes owned by the current
+ * route, released by the next nextRoute(). Anything registered on a scope whose route has
+ * ended is refused (and counted), never left running.
+ * rt.diagnostics(), rt.leaks()
+ * → loaded, failed and rolled-back features, duplicate tags, late registrations, what the
+ * current route holds, and the prefetch budget spent.
+ *
+ * Asset groups are transactional: a feature counts as loaded, runs its hook and fires `<prefix>:feature` only when
+ * every script loaded. When one fails, the stylesheets that attempt added (and no other feature wants) are taken out
+ * again and the failed script's tag is removed, so a retry fetches exactly what is missing. Scripts that did run are
+ * kept, so nothing runs twice.
+ * Tags the server already put in the document count as loaded. Global stubs (installStubs) keep inline onclick
+ * handlers working before their feature has loaded.
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OVWebRuntime) return;
+
+ /** The key an asset is known by: its path on this site, origin + path elsewhere; the query (?v=) never counts. */
+ function assetKey(src) {
+ try {
+ const u = new URL(src, root.location.href);
+ return u.origin === root.location.origin ? u.pathname : u.origin + u.pathname;
+ } catch { return String(src).split(/[?#]/)[0]; }
+ }
+
+ function create(o = {}) {
+ const FEATURES = o.features || {};
+ const ROUTES = (o.routes || []).map((r) => ({ re: r.path instanceof RegExp ? r.path : new RegExp(r.path), features: r.features || [] }));
+ const versions = o.versions || {};
+ const G = o.global || root;
+ const prefix = o.eventPrefix || 'ov';
+ const log = o.log || root.console;
+ const styleTimeout = o.styleTimeout || 4000;
+ const fragmentPath = o.fragmentPath || ((name) => `/fragments/${name}.html`);
+ const url = o.url || ((p) => { const h = versions[p]; return h ? `${p}${p.indexOf('?') === -1 ? '?' : '&'}v=${h}` : p; });
+ const d = { scripts: 0, styles: 0, late: 0, rolledBack: 0, failed: Object.create(null), scopes: 0, disposed: 0 };
+ const emit = (type, detail, target) => (target || document).dispatchEvent(new root.CustomEvent(type, { bubbles: true, detail }));
+ const failed = (kind, what, err) => { const f = d.failed[what] || (d.failed[what] = { kind, attempts: 0, error: '' }); f.attempts++; f.error = String((err && err.message) || err || ''); };
+
+ // ── Scripts and styles, once each ────────────────────────────────────────────────────────
+ const scripts = Object.create(null); // key → promise
+ const styles = Object.create(null); // key → promise
+ const styleEls = Object.create(null); // key → the <link> this runtime inserted
+ const wanted = Object.create(null); // key → Set of features loading or loaded that list it
+
+ function tagPromise(el, isScript) {
+ // A deferred script that has run fired `load` long ago; after DOMContentLoaded every parser-inserted script has run.
+ if (isScript && document.readyState !== 'loading' && !el.dataset.ovInjected) return Promise.resolve();
+ return new Promise((resolve) => {
+ el.addEventListener('load', () => resolve(), { once: true });
+ el.addEventListener('error', () => resolve(), { once: true });
+ if (isScript && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => resolve(), { once: true });
+ });
+ }
+ /** Tags already in the document (the server's, or another loader's) count as loaded or loading. */
+ function adoptExisting() {
+ document.querySelectorAll('script[src]').forEach((t) => { const k = assetKey(t.getAttribute('src')); if (!scripts[k]) scripts[k] = tagPromise(t, true); });
+ document.querySelectorAll('link[rel="stylesheet"][href]').forEach((l) => { const k = assetKey(l.getAttribute('href')); if (!styles[k]) styles[k] = l.sheet ? Promise.resolve() : tagPromise(l, false); });
+ }
+
+ /** attrs: { integrity, crossOrigin } for another origin's file. */
+ function loadScript(p, attrs) {
+ const k = assetKey(p);
+ if (scripts[k]) return scripts[k];
+ const s = document.createElement('script');
+ s.src = url(p);
+ s.async = false; // injected together → run in insertion order
+ s.dataset.ovInjected = '1';
+ if (attrs && attrs.integrity) s.integrity = attrs.integrity;
+ if (attrs && attrs.crossOrigin) s.crossOrigin = attrs.crossOrigin;
+ scripts[k] = new Promise((resolve, reject) => {
+ s.onload = () => { delete d.failed[k]; resolve(); };
+ s.onerror = () => {
+ delete scripts[k]; // a retry adds a fresh tag
+ s.remove();
+ const err = new Error(`Could not load ${p}`);
+ failed('script', k, err);
+ reject(err);
+ };
+ });
+ document.head.appendChild(s);
+ d.scripts++;
+ return scripts[k];
+ }
+
+ /** Never rejects: a missing stylesheet must not block a route (it waits at most styleTimeout ms). */
+ function loadStyle(p, added) {
+ const k = assetKey(p);
+ if (styles[k]) return styles[k];
+ const l = document.createElement('link');
+ l.rel = 'stylesheet';
+ l.href = url(p);
+ const pr = styles[k] = new Promise((resolve) => {
+ let done = false;
+ const finish = () => { if (!done) { done = true; resolve(); } };
+ l.onload = () => { delete d.failed[k]; finish(); };
+ l.onerror = () => {
+ if (styles[k] === pr) { delete styles[k]; delete styleEls[k]; }
+ l.remove();
+ failed('style', k, 'error');
+ finish();
+ };
+ root.setTimeout(finish, styleTimeout);
+ });
+ // o.styleSlot(p) → the node it goes before (a site's cascade order), or nothing: last in <head>.
+ const before = o.styleSlot ? o.styleSlot(p) : null;
+ if (before && before.parentNode) before.parentNode.insertBefore(l, before);
+ else document.head.appendChild(l);
+ styleEls[k] = l;
+ d.styles++;
+ if (added) added.push(k);
+ return pr;
+ }
+
+ // ── Markup fragments ─────────────────────────────────────────────────────────────────────
+ const fragments = Object.create(null);
+ function loadFragment(name, sectionId) {
+ const section = document.getElementById(sectionId);
+ if (!section || section.dataset.fragmentLoaded === '1') return Promise.resolve();
+ if (fragments[name]) return fragments[name];
+ section.setAttribute('aria-busy', 'true');
+ fragments[name] = root.fetch(url(fragmentPath(name)), { credentials: 'same-origin' })
+ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); })
+ .then((html) => {
+ // Inserted once, never replaced: players, previews and call tiles bind to these exact nodes.
+ if (section.dataset.fragmentLoaded !== '1') {
+ section.innerHTML = html;
+ section.dataset.fragmentLoaded = '1';
+ emit(`${prefix}:fragment`, { name }, section);
+ }
+ section.removeAttribute('aria-busy');
+ }, (err) => {
+ delete fragments[name];
+ section.removeAttribute('aria-busy');
+ failed('fragment', name, err);
+ throw err;
+ });
+ return fragments[name];
+ }
+
+ // ── Features (transactional asset groups) ─────────────────────────────────────────────────
+ const features = Object.create(null); // name → promise, in flight or loaded
+ const loaded = Object.create(null);
+ const afterRan = Object.create(null);
+
+ function load(name) {
+ const f = FEATURES[name];
+ if (!f) return Promise.resolve();
+ if (features[name]) return features[name];
+ const css = (f.css || []).map(assetKey);
+ css.forEach((k) => (wanted[k] || (wanted[k] = new Set())).add(name));
+ const added = [];
+ // The feature's markup goes in before its dependencies and scripts run: modules bind to it when they load.
+ const markup = f.fragment ? loadFragment(f.fragment, f.section || `page-${f.fragment}`) : Promise.resolve();
+ features[name] = markup
+ .then(() => Promise.all((f.deps || []).map(load)))
+ .then(() => Promise.all((f.css || []).map((p) => loadStyle(p, added)).concat((f.js || []).map((p) => loadScript(p)))))
+ .then(() => {
+ loaded[name] = true;
+ delete d.failed[name];
+ if (f.after && !afterRan[name] && typeof G[f.after] === 'function') {
+ afterRan[name] = true;
+ try { G[f.after](); } catch (e) { log.error(`[${prefix}] ${name} after-hook failed:`, e); }
+ }
+ emit(`${prefix}:feature`, { name });
+ }, (err) => {
+ delete features[name];
+ css.forEach((k) => wanted[k] && wanted[k].delete(name));
+ for (const k of added) {
+ if (wanted[k] && wanted[k].size) continue;
+ if (styleEls[k]) styleEls[k].remove();
+ delete styleEls[k]; delete styles[k];
+ d.rolledBack++;
+ }
+ failed('feature', name, err);
+ throw err;
+ });
+ return features[name];
+ }
+
+ function featuresFor(path) {
+ const out = [];
+ ROUTES.forEach((r) => { if (r.re.test(path)) r.features.forEach((n) => { if (out.indexOf(n) === -1) out.push(n); }); });
+ return out;
+ }
+ function route(path) { return Promise.all(featuresFor(path || root.location.pathname).map(load)); }
+
+ // ── Prefetch (download, never execute), within a budget ──────────────────────────────────
+ const pf = o.prefetch || {};
+ const budget = { count: pf.count == null ? 60 : pf.count, bytes: pf.bytes == null ? 3 * 1024 * 1024 : pf.bytes };
+ const sizes = pf.sizes || {};
+ const spent = { count: 0, bytes: 0, skipped: 0, constrained: 0 };
+ const prefetched = Object.create(null);
+ function constrained() {
+ const c = root.navigator && root.navigator.connection;
+ if (c && (c.saveData || /(^|-)2g$/.test(c.effectiveType || ''))) return true;
+ return !!root.navigator && typeof root.navigator.deviceMemory === 'number' && root.navigator.deviceMemory < 2;
+ }
+ function prefetchAsset(p) {
+ const k = assetKey(p);
+ if (prefetched[k] || scripts[k] || styles[k]) return;
+ const size = Number(sizes[p]) || 0;
+ if (spent.count + 1 > budget.count || spent.bytes + size > budget.bytes) { spent.skipped++; return; }
+ prefetched[k] = true; spent.count++; spent.bytes += size;
+ const l = document.createElement('link');
+ l.rel = 'prefetch';
+ l.href = url(p);
+ l.setAttribute('as', /\.css$/.test(k) ? 'style' : /\.js$/.test(k) ? 'script' : 'fetch');
+ document.head.appendChild(l);
+ }
+ function prefetch(name, seen) {
+ seen = seen || Object.create(null);
+ const f = FEATURES[name];
+ if (!f || seen[name] || features[name]) return;
+ if (constrained()) { spent.constrained++; return; }
+ seen[name] = true;
+ (f.deps || []).forEach((n) => prefetch(n, seen));
+ (f.css || []).concat(f.js || []).forEach(prefetchAsset);
+ if (f.fragment) prefetchAsset(fragmentPath(f.fragment));
+ }
+ function prefetchRoute(path) { featuresFor(path).forEach((n) => prefetch(n)); }
+
+ /** Hover, focus or touch on an in-site link: fetch that route's code. Returns a function that stops watching. */
+ function watchIntent(target) {
+ const t = target || document;
+ const on = (e) => {
+ const a = e.target && e.target.closest && e.target.closest('a[href^="/"]');
+ const href = a && a.getAttribute('href');
+ if (!href || href.charAt(1) === '/') return;
+ const path = href.split(/[?#]/)[0];
+ if (path) prefetchRoute(path);
+ };
+ const kinds = [['pointerover', { passive: true }], ['focusin', undefined], ['touchstart', { passive: true }]];
+ kinds.forEach(([k, opts]) => t.addEventListener(k, on, opts));
+ return () => kinds.forEach(([k, opts]) => t.removeEventListener(k, on, opts));
+ }
+ /** The `idle` features of the path's routes, prefetched once the page has settled (load + 4 s + idle). */
+ function idlePrefetch(path) {
+ const idle = root.requestIdleCallback || ((fn) => root.setTimeout(fn, 3000));
+ const go = () => root.setTimeout(() => idle(() => {
+ featuresFor(path || root.location.pathname).forEach((n) => (FEATURES[n].idle || []).forEach((m) => prefetch(m)));
+ }, { timeout: 15000 }), 4000);
+ if (document.readyState === 'complete') go();
+ else root.addEventListener('load', go, { once: true });
+ }
+
+ // ── Route generations and scopes ─────────────────────────────────────────────────────────
+ let generation = 0;
+ let current = null;
+
+ function makeScope(gen) {
+ const held = new Set();
+ const controller = typeof root.AbortController === 'function' ? new root.AbortController() : null;
+ let disposed = false;
+ d.scopes++;
+ const late = (kind) => { d.late++; if (o.debug) log.warn(`[${prefix}] ${kind} registered after its route ended (generation ${gen}); ignored`); };
+ const hold = (kind, fn) => { const h = { kind, fn }; held.add(h); return h; };
+ const scope = {
+ gen,
+ signal: controller ? controller.signal : undefined,
+ interval(fn, ms) {
+ if (disposed) { late('interval'); return 0; }
+ const id = root.setInterval(fn, ms);
+ hold('interval', () => root.clearInterval(id));
+ return id;
+ },
+ timeout(fn, ms) {
+ if (disposed) { late('timeout'); return 0; }
+ let h = null;
+ const id = root.setTimeout(function () { held.delete(h); return fn.apply(this, arguments); }, ms);
+ h = hold('timeout', () => root.clearTimeout(id));
+ return id;
+ },
+ listen(target, type, fn, opts) {
+ if (!target) return;
+ if (disposed) { late('listener'); return; }
+ target.addEventListener(type, fn, opts);
+ hold('listener', () => target.removeEventListener(type, fn, opts));
+ },
+ observe(observer) {
+ if (disposed) { late('observer'); try { observer.disconnect(); } catch { /* */ } return observer; }
+ hold('observer', () => { try { observer.disconnect(); } catch { /* */ } });
+ return observer;
+ },
+ onDispose(fn) {
+ if (disposed) { late('onDispose'); try { fn(); } catch { /* */ } return; }
+ hold('onDispose', fn);
+ },
+ /** fetch() cancelled when the route ends (unless init brings its own signal). */
+ fetch(input, init) {
+ if (disposed) { late('fetch'); return Promise.reject(Object.assign(new Error('The route has ended'), { name: 'AbortError' })); }
+ return root.fetch(input, Object.assign({ signal: scope.signal }, init));
+ },
+ /** A scope for one component (a dialog, a player) that can end early and ends with this one anyway. */
+ child() {
+ const c = makeScope(gen);
+ if (disposed) { late('child'); c.dispose(); return c; }
+ const h = hold('child', () => c.dispose());
+ c.onDispose(() => held.delete(h));
+ return c;
+ },
+ /** What this scope holds right now, by kind. */
+ held() {
+ const out = {};
+ held.forEach((h) => { out[h.kind] = (out[h.kind] || 0) + 1; });
+ return out;
+ },
+ get disposed() { return disposed; },
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ d.disposed++;
+ if (controller) try { controller.abort(); } catch { /* */ }
+ const list = Array.from(held).reverse();
+ held.clear();
+ for (const h of list) { try { h.fn(); } catch (e) { log.warn(`[${prefix}] scope cleanup failed:`, e); } }
+ },
+ };
+ return scope;
+ }
+ function scope() { if (!current) current = makeScope(generation); return current; }
+ /** A new route: everything the previous one owned is released. Returns the new generation. */
+ function nextRoute() {
+ generation++;
+ if (current) current.dispose();
+ current = makeScope(generation);
+ return generation;
+ }
+
+ // ── Stubs for inline handlers ────────────────────────────────────────────────────────────
+ function installStubs() {
+ Object.keys(FEATURES).forEach((name) => {
+ (FEATURES[name].stubs || []).forEach((fn) => {
+ if (typeof G[fn] === 'function') return;
+ const stub = function () {
+ const args = arguments; const self = this;
+ return load(name).then(() => {
+ if (G[fn] !== stub && typeof G[fn] === 'function') return G[fn].apply(self, args);
+ log.warn(`[${prefix}] ${fn} is unavailable after loading ${name}`);
+ }, (err) => {
+ log.error(`[${prefix}]`, err);
+ if (typeof o.onStubError === 'function') o.onStubError(err, name);
+ });
+ };
+ stub.__ovStub = name;
+ G[fn] = stub;
+ });
+ });
+ }
+
+ // ── Recoverable failure UI for a route ───────────────────────────────────────────────────
+ const RE = Object.assign({ icon: '', button: 'btn btn-primary', title: 'This page could not load.', text: 'Check your connection, then try again.', retry: 'Try again' }, o.routeError);
+ function showRouteError(pageId, err, retry) {
+ const page = document.getElementById(pageId);
+ if (!page) return;
+ let box = page.querySelector(':scope > .ov-route-error');
+ if (!box) {
+ box = document.createElement('div');
+ box.className = 'ov-route-error';
+ box.setAttribute('role', 'alert');
+ page.insertBefore(box, page.firstChild);
+ }
+ box.textContent = '';
+ if (RE.icon) { const i = document.createElement('i'); i.className = RE.icon; i.setAttribute('aria-hidden', 'true'); box.appendChild(i); }
+ const msg = document.createElement('div');
+ const strong = document.createElement('strong'); strong.textContent = RE.title;
+ const span = document.createElement('span'); span.textContent = RE.text;
+ msg.appendChild(strong); msg.appendChild(span); box.appendChild(msg);
+ const btn = document.createElement('button');
+ btn.type = 'button'; btn.className = RE.button; btn.textContent = RE.retry;
+ btn.addEventListener('click', () => { box.remove(); retry(); }, { once: true });
+ box.appendChild(btn);
+ log.error(`[${prefix}] route failed to load:`, err);
+ }
+
+ // ── Diagnostics ──────────────────────────────────────────────────────────────────────────
+ function duplicates() {
+ const n = Object.create(null);
+ document.querySelectorAll('script[src], link[rel="stylesheet"][href]').forEach((el) => {
+ const k = assetKey(el.getAttribute('src') || el.getAttribute('href'));
+ n[k] = (n[k] || 0) + 1;
+ });
+ return Object.keys(n).filter((k) => n[k] > 1).map((k) => ({ asset: k, count: n[k] }));
+ }
+ function diagnostics() {
+ return {
+ generation,
+ features: {
+ loaded: Object.keys(loaded),
+ loading: Object.keys(features).filter((n) => !loaded[n]),
+ failed: Object.keys(d.failed).filter((k) => d.failed[k].kind === 'feature').map((k) => ({ name: k, attempts: d.failed[k].attempts, error: d.failed[k].error })),
+ },
+ assets: {
+ scripts: Object.keys(scripts).length, styles: Object.keys(styles).length,
+ injected: { scripts: d.scripts, styles: d.styles },
+ failed: Object.keys(d.failed).filter((k) => d.failed[k].kind !== 'feature').map((k) => ({ asset: k, kind: d.failed[k].kind, attempts: d.failed[k].attempts })),
+ rolledBack: d.rolledBack,
+ duplicates: duplicates(),
+ },
+ scopes: { created: d.scopes, disposed: d.disposed, late: d.late, current: current ? current.held() : {} },
+ prefetch: Object.assign({ budget: Object.assign({}, budget) }, spent),
+ };
+ }
+ /** Plain sentences about anything that looks like a leak; empty when all is well. */
+ function leaks() {
+ const out = [];
+ duplicates().forEach((x) => out.push(`${x.asset} is in the document ${x.count} times`));
+ if (d.late) out.push(`${d.late} registration(s) arrived after their route ended (refused)`);
+ return out;
+ }
+
+ return {
+ load, route, featuresFor, prefetch, prefetchRoute, loadScript, loadStyle, loadFragment, url, assetKey,
+ gen: () => generation,
+ isCurrent: (g) => g === generation,
+ scope, nextRoute, installStubs, showRouteError, adoptExisting, watchIntent, idlePrefetch, diagnostics, leaks,
+ isLoaded: (name) => !!features[name],
+ /** Adopt the server's tags, load `initial` features, watch link intent, prefetch the idle features. */
+ boot(b = {}) {
+ const start = () => {
+ adoptExisting();
+ (b.initial || []).forEach((n) => { load(n).catch(() => { /* recorded in diagnostics */ }); });
+ if (b.intent !== false) watchIntent(document);
+ if (b.idle !== false) idlePrefetch();
+ };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
+ else start();
+ },
+ };
+ }
+
+ root.OVWebRuntime = { create, assetKey };
+ if (typeof module !== 'undefined' && module.exports) module.exports = root.OVWebRuntime;
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="admin.openvibe.network — One operator console for every service.">
<meta name="twitter:title" content="admin.openvibe.network — One operator console for every service.">
<meta name="twitter:description" content="admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.">
<meta name="twitter:description" content="admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://admin.openvibe.network/#site","url":"https://admin.openvibe.network/","name":"admin.openvibe.network","description":"admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://admin.openvibe.network/#page","url":"https://admin.openvibe.network/","name":"admin.openvibe.network — One operator console for every service.","description":"admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.","isPartOf":{"@id":"https://admin.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"admin.openvibe.network","item":"https://admin.openvibe.network/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://admin.openvibe.network/#site","url":"https://admin.openvibe.network/","name":"admin.openvibe.network","description":"admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://admin.openvibe.network/#page","url":"https://admin.openvibe.network/","name":"admin.openvibe.network — One operator console for every service.","description":"admin.openvibe.network will be the federated operator console: users, roles, bans, registry and policy, event queues, storage, billing, migrations and emergency controls, discovered from each service rather than hard-wired.","isPartOf":{"@id":"https://admin.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"admin.openvibe.network","item":"https://admin.openvibe.network/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'admin', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'admin.openvibe.network' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'admin', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'admin.openvibe.network' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "admin",
"service": "admin",
- "release": "14f0403f18ea",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "7f7ac1ecbc83",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://admin.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://admin.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": null,
"launchWaitsFor": null,
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="ai.openvibe.network — AI as a service, for every OpenVibe product.">
<meta name="twitter:title" content="ai.openvibe.network — AI as a service, for every OpenVibe product.">
<meta name="twitter:description" content="OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.">
<meta name="twitter:description" content="OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://ai.openvibe.network/#site","url":"https://ai.openvibe.network/","name":"ai.openvibe.network","description":"OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://ai.openvibe.network/#page","url":"https://ai.openvibe.network/","name":"ai.openvibe.network — AI as a service, for every OpenVibe product.","description":"OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.","isPartOf":{"@id":"https://ai.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"ai.openvibe.network","item":"https://ai.openvibe.network/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://ai.openvibe.network/#site","url":"https://ai.openvibe.network/","name":"ai.openvibe.network","description":"OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://ai.openvibe.network/#page","url":"https://ai.openvibe.network/","name":"ai.openvibe.network — AI as a service, for every OpenVibe product.","description":"OpenVibe.AI is the shared AI layer of the OpenVibe network: providers and models, routing, prompt templates, workflows, runs and quotas, so each product stops wiring its own. It runs today as an internal service that OpenVibe.Live and OpenVibe.Network call; ai.openvibe.network has no public page or API yet.","isPartOf":{"@id":"https://ai.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"ai.openvibe.network","item":"https://ai.openvibe.network/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'ai', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'ai.openvibe.network' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'ai', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'ai.openvibe.network' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "ai",
"service": "ai",
- "release": "9afb11c99afa",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "bf0b27e48124",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://ai.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://ai.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "a public API with its own access rules; today only other OpenVibe services call it",
"launchWaitsFor": "a public API with its own access rules; today only other OpenVibe services call it",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="api.openvibe.network — The network, machine-readable.">
<meta name="twitter:title" content="api.openvibe.network — The network, machine-readable.">
<meta name="twitter:description" content="api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.">
<meta name="twitter:description" content="api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://api.openvibe.network/#site","url":"https://api.openvibe.network/","name":"api.openvibe.network","description":"api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://api.openvibe.network/#page","url":"https://api.openvibe.network/","name":"api.openvibe.network — The network, machine-readable.","description":"api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.","isPartOf":{"@id":"https://api.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"api.openvibe.network","item":"https://api.openvibe.network/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://api.openvibe.network/#site","url":"https://api.openvibe.network/","name":"api.openvibe.network","description":"api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://api.openvibe.network/#page","url":"https://api.openvibe.network/","name":"api.openvibe.network — The network, machine-readable.","description":"api.openvibe.network is the planned home of the network's public APIs. The ecosystem registry (services, capabilities, event topics, namespaces and releases, backed by the OpenVibe contracts) already answers on openvibe.network; this subdomain is not routed to it yet.","isPartOf":{"@id":"https://api.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"api.openvibe.network","item":"https://api.openvibe.network/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -252,9 +252,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -252,9 +252,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'api', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'api.openvibe.network' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'api', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'api.openvibe.network' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "api",
"service": "api",
- "release": "dba0ab1ed6a1",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "1b08f39023f9",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://api.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://api.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": null,
"launchWaitsFor": null,
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="auth.openvibe.network — Sign in, once, for the whole network.">
<meta name="twitter:title" content="auth.openvibe.network — Sign in, once, for the whole network.">
<meta name="twitter:description" content="auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.">
<meta name="twitter:description" content="auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://auth.openvibe.network/#site","url":"https://auth.openvibe.network/","name":"auth.openvibe.network","description":"auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://auth.openvibe.network/#page","url":"https://auth.openvibe.network/","name":"auth.openvibe.network — Sign in, once, for the whole network.","description":"auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.","isPartOf":{"@id":"https://auth.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"auth.openvibe.network","item":"https://auth.openvibe.network/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://auth.openvibe.network/#site","url":"https://auth.openvibe.network/","name":"auth.openvibe.network","description":"auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://auth.openvibe.network/#page","url":"https://auth.openvibe.network/","name":"auth.openvibe.network — Sign in, once, for the whole network.","description":"auth.openvibe.network will be the dedicated home of OpenVibe identity: sign-in, registration, OAuth 2.0 / OpenID Connect for apps, sessions and linked accounts. Today all of this runs inside openvibe.network itself.","isPartOf":{"@id":"https://auth.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"auth.openvibe.network","item":"https://auth.openvibe.network/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -251,9 +251,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -251,9 +251,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'auth', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'auth.openvibe.network' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'auth', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'auth.openvibe.network' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "auth",
"service": "auth",
- "release": "59fa3ef72cd0",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "fe03dc632477",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://auth.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://auth.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": null,
"launchWaitsFor": null,
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="OpenRe.Stream — Ingest and restream, in the open.">
<meta name="twitter:title" content="OpenRe.Stream — Ingest and restream, in the open.">
<meta name="twitter:description" content="OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.">
<meta name="twitter:description" content="OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openre.stream/#site","url":"https://openre.stream/","name":"OpenRe.Stream","description":"OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openre.stream/#page","url":"https://openre.stream/","name":"OpenRe.Stream — Ingest and restream, in the open.","description":"OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.","isPartOf":{"@id":"https://openre.stream/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenRe.Stream","item":"https://openre.stream/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openre.stream/#site","url":"https://openre.stream/","name":"OpenRe.Stream","description":"OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openre.stream/#page","url":"https://openre.stream/","name":"OpenRe.Stream — Ingest and restream, in the open.","description":"OpenRe.Stream is the ingest and restream service split out of OpenVibe.Live: RTMP ingest with stream keys and lifecycle, and restreaming to RTMP, RTMPS and SRT destinations, with WHIP to follow. Broadcasters still go live through OpenVibe.Live until their stream moves over.","isPartOf":{"@id":"https://openre.stream/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenRe.Stream","item":"https://openre.stream/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -251,9 +251,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -251,9 +251,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'stream', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenRe.Stream' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'stream', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenRe.Stream' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "stream",
"service": "stream",
- "release": "2a3844407d96",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "efb64c0c5635",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://openre.stream/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://openre.stream/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "the stream slots to move from OpenVibe.Live's ingest to OpenRe.Stream, one at a time; no stream uses it yet",
"launchWaitsFor": "the stream slots to move from OpenVibe.Live's ingest to OpenRe.Stream, one at a time; no stream uses it yet",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="OpenVibe.Coupons — Codes that actually work.">
<meta name="twitter:title" content="OpenVibe.Coupons — Codes that actually work.">
<meta name="twitter:description" content="OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.">
<meta name="twitter:description" content="OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.coupons/#site","url":"https://openvibe.coupons/","name":"OpenVibe.Coupons","description":"OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.coupons/#page","url":"https://openvibe.coupons/","name":"OpenVibe.Coupons — Codes that actually work.","description":"OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.","isPartOf":{"@id":"https://openvibe.coupons/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Coupons","item":"https://openvibe.coupons/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.coupons/#site","url":"https://openvibe.coupons/","name":"OpenVibe.Coupons","description":"OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.coupons/#page","url":"https://openvibe.coupons/","name":"OpenVibe.Coupons — Codes that actually work.","description":"OpenVibe.Coupons is community-checked coupon discovery: codes matched to stores, validity reported by real people, expirations tracked, and a browser helper on the way.","isPartOf":{"@id":"https://openvibe.coupons/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Coupons","item":"https://openvibe.coupons/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'coupons', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Coupons' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'coupons', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Coupons' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "coupons",
"service": "coupons",
- "release": "f663069bb476",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "1d53415b3ea5",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://openvibe.coupons/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://openvibe.coupons/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "a person to verify a coupons source's terms and switch it on",
"launchWaitsFor": "a person to verify a coupons source's terms and switch it on",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="OpenVibe.Deals — Deals worth seeing.">
<meta name="twitter:title" content="OpenVibe.Deals — Deals worth seeing.">
<meta name="twitter:description" content="OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.">
<meta name="twitter:description" content="OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.deals/#site","url":"https://openvibe.deals/","name":"OpenVibe.Deals","description":"OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.deals/#page","url":"https://openvibe.deals/","name":"OpenVibe.Deals — Deals worth seeing.","description":"OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.","isPartOf":{"@id":"https://openvibe.deals/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Deals","item":"https://openvibe.deals/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.deals/#site","url":"https://openvibe.deals/","name":"OpenVibe.Deals","description":"OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.deals/#page","url":"https://openvibe.deals/","name":"OpenVibe.Deals — Deals worth seeing.","description":"OpenVibe.Deals is community deal discovery for the OpenVibe network: deals people vote on, hotness that reflects real interest, keyword alerts, price comparisons and honest discussion.","isPartOf":{"@id":"https://openvibe.deals/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Deals","item":"https://openvibe.deals/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'deals', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Deals' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'deals', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Deals' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "deals",
"service": "deals",
- "release": "f5764f4254a7",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "94029ef33dc4",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://openvibe.deals/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://openvibe.deals/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "a person to verify a deals source's terms and switch it on",
"launchWaitsFor": "a person to verify a deals source's terms and switch it on",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="OpenVibe.Host — Static sites for OpenVibe projects.">
<meta name="twitter:title" content="OpenVibe.Host — Static sites for OpenVibe projects.">
<meta name="twitter:description" content="OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.">
<meta name="twitter:description" content="OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.host/#site","url":"https://openvibe.host/","name":"OpenVibe.Host","description":"OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.host/#page","url":"https://openvibe.host/","name":"OpenVibe.Host — Static sites for OpenVibe projects.","description":"OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.","isPartOf":{"@id":"https://openvibe.host/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Host","item":"https://openvibe.host/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.host/#site","url":"https://openvibe.host/","name":"OpenVibe.Host","description":"OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.host/#page","url":"https://openvibe.host/","name":"OpenVibe.Host — Static sites for OpenVibe projects.","description":"OpenVibe.Host is the network's deployment tooling and, next, static site hosting for OpenVibe projects. The tooling (ovhost) already runs on the network's host: deploys with automatic rollback, backups and restore drills for the network's own services. The hosting part will let a project upload a folder of HTML, CSS, JavaScript and images, serve it at <site>.openvibe.host and roll back to any earlier deploy in one step. Static files only: nothing uploaded is run as code.","isPartOf":{"@id":"https://openvibe.host/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Host","item":"https://openvibe.host/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -251,9 +251,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -251,9 +251,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'host', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Host' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'host', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Host' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "host",
"service": "host",
- "release": "df0d28a88e98",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "40bc066c9010",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://openvibe.host/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://openvibe.host/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "the owner's go on its launch checklist",
"launchWaitsFor": "the owner's go on its launch checklist",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="OpenVibe.News — Sourced. Summarised. Many angles.">
<meta name="twitter:title" content="OpenVibe.News — Sourced. Summarised. Many angles.">
<meta name="twitter:description" content="OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.">
<meta name="twitter:description" content="OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.news/#site","url":"https://openvibe.news/","name":"OpenVibe.News","description":"OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.news/#page","url":"https://openvibe.news/","name":"OpenVibe.News — Sourced. Summarised. Many angles.","description":"OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.","isPartOf":{"@id":"https://openvibe.news/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.News","item":"https://openvibe.news/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.news/#site","url":"https://openvibe.news/","name":"OpenVibe.News","description":"OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.news/#page","url":"https://openvibe.news/","name":"OpenVibe.News — Sourced. Summarised. Many angles.","description":"OpenVibe.News aggregates the news with transparency: sourced stories, summaries that show their sources, multiple angles side by side, topic pages and framing you control — not an algorithm you can't see.","isPartOf":{"@id":"https://openvibe.news/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.News","item":"https://openvibe.news/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'news', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.News' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'news', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.News' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "news",
"service": "news",
- "release": "56eb30198d36",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "f799922d60d7",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://openvibe.news/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://openvibe.news/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "a person to verify a news source's terms and an editor to publish the first reviewed story",
"launchWaitsFor": "a person to verify a news source's terms and an editor to publish the first reviewed story",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="OpenVibe.Reviews — What people actually say.">
<meta name="twitter:title" content="OpenVibe.Reviews — What people actually say.">
<meta name="twitter:description" content="OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.">
<meta name="twitter:description" content="OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.reviews/#site","url":"https://openvibe.reviews/","name":"OpenVibe.Reviews","description":"OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.reviews/#page","url":"https://openvibe.reviews/","name":"OpenVibe.Reviews — What people actually say.","description":"OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.","isPartOf":{"@id":"https://openvibe.reviews/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Reviews","item":"https://openvibe.reviews/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.reviews/#site","url":"https://openvibe.reviews/","name":"OpenVibe.Reviews","description":"OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.reviews/#page","url":"https://openvibe.reviews/","name":"OpenVibe.Reviews — What people actually say.","description":"OpenVibe.Reviews gathers what people say about products, services, employers, hosts and communities across many sources — with provenance, without fake ratings — and lets the community discuss it.","isPartOf":{"@id":"https://openvibe.reviews/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Reviews","item":"https://openvibe.reviews/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'reviews', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Reviews' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'reviews', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Reviews' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "reviews",
"service": "reviews",
- "release": "d29c18b710ef",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "3ed60955d255",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://openvibe.reviews/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://openvibe.reviews/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "a person to verify a review source's terms and an editor to publish the first reviewed summary",
"launchWaitsFor": "a person to verify a review source's terms and an editor to publish the first reviewed summary",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="OpenVibe.Tips — Support the people you watch.">
<meta name="twitter:title" content="OpenVibe.Tips — Support the people you watch.">
<meta name="twitter:description" content="OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.">
<meta name="twitter:description" content="OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.tips/#site","url":"https://openvibe.tips/","name":"OpenVibe.Tips","description":"OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.tips/#page","url":"https://openvibe.tips/","name":"OpenVibe.Tips — Support the people you watch.","description":"OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.","isPartOf":{"@id":"https://openvibe.tips/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Tips","item":"https://openvibe.tips/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.tips/#site","url":"https://openvibe.tips/","name":"OpenVibe.Tips","description":"OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.tips/#page","url":"https://openvibe.tips/","name":"OpenVibe.Tips — Support the people you watch.","description":"OpenVibe.Tips is the tipping and alerts layer of the OpenVibe network: tips, donation goals, superchats, paid text-to-speech and media requests, with alert overlays for creators.","isPartOf":{"@id":"https://openvibe.tips/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Tips","item":"https://openvibe.tips/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'tips', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Tips' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'tips', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Tips' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "tips",
"service": "tips",
- "release": "529fe3b48ed7",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "47ba1331fdca",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://openvibe.tips/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://openvibe.tips/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "the payments cutover to OpenVibe.Billing and a security review; tips keep working on OpenVibe.Live meanwhile",
"launchWaitsFor": "the payments cutover to OpenVibe.Billing and a security review; tips keep working on OpenVibe.Live meanwhile",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="OpenVibe.Trade — Watch, discuss, decide.">
<meta name="twitter:title" content="OpenVibe.Trade — Watch, discuss, decide.">
<meta name="twitter:description" content="OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.">
<meta name="twitter:description" content="OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.trade/#site","url":"https://openvibe.trade/","name":"OpenVibe.Trade","description":"OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.trade/#page","url":"https://openvibe.trade/","name":"OpenVibe.Trade — Watch, discuss, decide.","description":"OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.","isPartOf":{"@id":"https://openvibe.trade/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Trade","item":"https://openvibe.trade/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.trade/#site","url":"https://openvibe.trade/","name":"OpenVibe.Trade","description":"OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.trade/#page","url":"https://openvibe.trade/","name":"OpenVibe.Trade — Watch, discuss, decide.","description":"OpenVibe.Trade is the market intelligence room of the OpenVibe network: watchlists, alerts, summaries and community discussion for markets, collectibles and deals — information and tools, never advice.","isPartOf":{"@id":"https://openvibe.trade/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.Trade","item":"https://openvibe.trade/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'trade', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Trade' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'trade', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.Trade' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "trade",
"service": "trade",
- "release": "66b92483e1ef",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "141aea931221",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://openvibe.trade/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://openvibe.trade/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "a verified data source and editors adding the first instruments",
"launchWaitsFor": "a verified data source and editors adding the first instruments",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="OpenVibe.VIP — Memberships, network-wide.">
<meta name="twitter:title" content="OpenVibe.VIP — Memberships, network-wide.">
<meta name="twitter:description" content="OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.">
<meta name="twitter:description" content="OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.vip/#site","url":"https://openvibe.vip/","name":"OpenVibe.VIP","description":"OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.vip/#page","url":"https://openvibe.vip/","name":"OpenVibe.VIP — Memberships, network-wide.","description":"OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.","isPartOf":{"@id":"https://openvibe.vip/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.VIP","item":"https://openvibe.vip/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://openvibe.vip/#site","url":"https://openvibe.vip/","name":"OpenVibe.VIP","description":"OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://openvibe.vip/#page","url":"https://openvibe.vip/","name":"OpenVibe.VIP — Memberships, network-wide.","description":"OpenVibe.VIP is the membership layer of the OpenVibe network: creator memberships, channel subscriptions, VIP tiers and perks that unlock across Live, Community, Blog and more.","isPartOf":{"@id":"https://openvibe.vip/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"OpenVibe.VIP","item":"https://openvibe.vip/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'vip', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.VIP' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'vip', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'OpenVibe.VIP' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "vip",
"service": "vip",
- "release": "24ded64c1dc2",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "53c52ca7ae37",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://openvibe.vip/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://openvibe.vip/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": "the payments cutover to OpenVibe.Billing; checkout stays closed until then",
"launchWaitsFor": "the payments cutover to OpenVibe.Billing; checkout stays closed until then",
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="realtime.openvibe.network — Closed: realtime delivery is part of OpenVibe.Events.">
<meta name="twitter:title" content="realtime.openvibe.network — Closed: realtime delivery is part of OpenVibe.Events.">
<meta name="twitter:description" content="OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.">
<meta name="twitter:description" content="OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://realtime.openvibe.network/#site","url":"https://realtime.openvibe.network/","name":"realtime.openvibe.network","description":"OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://realtime.openvibe.network/#page","url":"https://realtime.openvibe.network/","name":"realtime.openvibe.network — Closed: realtime delivery is part of OpenVibe.Events.","description":"OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.","isPartOf":{"@id":"https://realtime.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"realtime.openvibe.network","item":"https://realtime.openvibe.network/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://realtime.openvibe.network/#site","url":"https://realtime.openvibe.network/","name":"realtime.openvibe.network","description":"OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://realtime.openvibe.network/#page","url":"https://realtime.openvibe.network/","name":"realtime.openvibe.network — Closed: realtime delivery is part of OpenVibe.Events.","description":"OpenVibe.Realtime was planned as a separate service for live updates in the browser. Decision record ADR-005 (accepted 2026-09-22) closed it: browser realtime is part of OpenVibe.Events, as server-sent events with cursor resume at events.openvibe.network/realtime/stream. This address serves only this notice.","isPartOf":{"@id":"https://realtime.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"realtime.openvibe.network","item":"https://realtime.openvibe.network/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -242,9 +242,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -242,9 +242,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'realtime', apiBase: 'https://openvibe.network', links: [{ label: 'Where to go', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'realtime.openvibe.network' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'realtime', apiBase: 'https://openvibe.network', links: [{ label: 'Where to go', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'realtime.openvibe.network' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "realtime",
"service": "realtime",
- "release": "b7989d309d34",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "14c08a7c978e",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": null,
"launchWaitsFor": null,
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="status.openvibe.network — Is OpenVibe up?">
<meta name="twitter:title" content="status.openvibe.network — Is OpenVibe up?">
<meta name="twitter:description" content="OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.">
<meta name="twitter:description" content="OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://status.openvibe.network/#site","url":"https://status.openvibe.network/","name":"status.openvibe.network","description":"OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://status.openvibe.network/#page","url":"https://status.openvibe.network/","name":"status.openvibe.network — Is OpenVibe up?","description":"OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.","isPartOf":{"@id":"https://status.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"status.openvibe.network","item":"https://status.openvibe.network/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://status.openvibe.network/#site","url":"https://status.openvibe.network/","name":"status.openvibe.network","description":"OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://status.openvibe.network/#page","url":"https://status.openvibe.network/","name":"status.openvibe.network — Is OpenVibe up?","description":"OpenVibe.Network checks every OpenVibe service about once a minute (its readiness endpoint and the release it runs) and publishes what it saw at openvibe.network/status. This address points there.","isPartOf":{"@id":"https://status.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"status.openvibe.network","item":"https://status.openvibe.network/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -282,9 +282,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -282,9 +282,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'status', apiBase: 'https://openvibe.network', links: [{ label: 'Where to go', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'status.openvibe.network' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'status', apiBase: 'https://openvibe.network', links: [{ label: 'Where to go', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'status.openvibe.network' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "status",
"service": "status",
- "release": "86ce11cc5012",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "fe837da2e2e9",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": null,
"launchWaitsFor": null,
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
<meta name="twitter:title" content="themes.openvibe.network — Your look, on every site.">
<meta name="twitter:title" content="themes.openvibe.network — Your look, on every site.">
<meta name="twitter:description" content="themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.">
<meta name="twitter:description" content="themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://themes.openvibe.network/#site","url":"https://themes.openvibe.network/","name":"themes.openvibe.network","description":"themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://themes.openvibe.network/#page","url":"https://themes.openvibe.network/","name":"themes.openvibe.network — Your look, on every site.","description":"themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.","isPartOf":{"@id":"https://themes.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-26","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"themes.openvibe.network","item":"https://themes.openvibe.network/"}]}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://themes.openvibe.network/#site","url":"https://themes.openvibe.network/","name":"themes.openvibe.network","description":"themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.","inLanguage":"en","publisher":{"@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"]},"isPartOf":{"@type":"WebSite","@id":"https://openvibe.network/#site","name":"OpenVibe.Network","url":"https://openvibe.network/"}},{"@type":"WebPage","@id":"https://themes.openvibe.network/#page","url":"https://themes.openvibe.network/","name":"themes.openvibe.network — Your look, on every site.","description":"themes.openvibe.network will be the theme catalog and API: built-in and community themes, previews, per-site overrides, import/export and the shared design tokens every OpenVibe site renders.","isPartOf":{"@id":"https://themes.openvibe.network/#site"},"datePublished":"2026-09-17","dateModified":"2026-09-27","breadcrumb":{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"OpenVibe","item":"https://openvibe.network/"},{"@type":"ListItem","position":2,"name":"themes.openvibe.network","item":"https://themes.openvibe.network/"}]}}]}</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
<script>(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();</script>
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=d9b9af081efe" defer></script>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></noscript>
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -250,9 +250,9 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
</span>
</span>
</div>
</div>
</div></footer>
</div></footer>
-<script src="https://openvibe.network/shared/ov-mark.js" async></script>
-<script src="https://openvibe.network/shared/navbar.js"></script>
-<script src="https://openvibe.network/shared/footer.js"></script>
+<script src="/shared/ov-mark.js?v=96079ebf2943" async></script>
+<script src="/shared/navbar.js?v=b50d9f47fc34"></script>
+<script src="/shared/footer.js?v=b99e512398d5"></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'themes', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'themes.openvibe.network' } }); } catch (e) {} }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'themes', apiBase: 'https://openvibe.network', links: [{ label: 'What it will be', href: '#what' }, { label: 'The network', href: 'https://openvibe.network/#network', external: false }], history: { type: 'page', title: 'themes.openvibe.network' } }); } catch (e) {} }
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
{
{
"service": "themes",
"service": "themes",
- "release": "d63add6d31ee",
- "released_at": "2026-09-26T00:00:00.000Z",
+ "release": "efa585a657c9",
+ "released_at": "2026-09-27T00:00:00.000Z",
"booted_at": null,
"booted_at": null,
"contracts_version": null,
"contracts_version": null,
"packages": {
"packages": {
- "openvibe-shared": "1.22.0"
+ "openvibe-shared": "1.23.0"
},
},
"min_client_release": null,
"min_client_release": null,
"mixed_version_window_hours": 24,
"mixed_version_window_hours": 24,
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url><loc>https://themes.openvibe.network/</loc><lastmod>2026-09-26</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
+ <url><loc>https://themes.openvibe.network/</loc><lastmod>2026-09-27</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
</urlset>
</urlset>
@@ -18,6 +18,6 @@
@@ -18,6 +18,6 @@
"launchWaitsFor": null,
"launchWaitsFor": null,
"factsAsOf": "2026-09-24",
"factsAsOf": "2026-09-24",
"network": "https://openvibe.network",
"network": "https://openvibe.network",
- "updated": "2026-09-26",
+ "updated": "2026-09-27",
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
"note": "Static page served by OpenVibers/OpenVibe.Sites; service facts come from each repository's STATUS.json and the Network registry (facts.json). Removed from sites.json in the same release that the real service takes over this domain."
}
}
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"": {
"": {
"name": "openvibe-sites",
"name": "openvibe-sites",
"dependencies": {
"dependencies": {
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"
},
},
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -119,9 +119,9 @@
@@ -119,9 +119,9 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.22.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
- "integrity": "sha512-FtqbR8h6YphOnJNqklJED3HyqvTwZJl58gyiZ3QgzmZtZcA16O2NMGq67O4p7grLojwaIG5DhXDMgmZcpScxJQ==",
+ "version": "1.23.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
+ "integrity": "sha512-9B3vXn5P7JjHWrp3cNh7ZynLOmCUgw9cuz6moksU3fyuIfy7uoepa2wJmQeSyLoPU8q/zKRJVeBVzPfdhr0z3g==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"test": "node test/build.test.js && node test/deploy-wrapper.test.js"
"test": "node test/build.test.js && node test/deploy-wrapper.test.js"
},
},
"dependencies": {
"dependencies": {
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0"
},
},
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -43,6 +43,14 @@ for (const site of catalog.sites) {
@@ -43,6 +43,14 @@ for (const site of catalog.sites) {
assert.strictEqual(rel.service, site.tld, `${d}: release.json service`);
assert.strictEqual(rel.service, site.tld, `${d}: release.json service`);
assert.match(rel.release, /^[0-9a-f]{12}$/, `${d}: release.json release`);
assert.match(rel.release, /^[0-9a-f]{12}$/, `${d}: release.json release`);
assert.match(read(`deploy/nginx/${d}.conf`), /location = \/release\.json \{/, `${d}: nginx serves /release.json`);
assert.match(read(`deploy/nginx/${d}.conf`), /location = \/release\.json \{/, `${d}: nginx serves /release.json`);
+ // D42: the frame from this repository's pin (dist/_shared/, aliased at /shared/), never openvibe.network's.
+ assert.doesNotMatch(html, /https:\/\/openvibe\.network\/shared\/[\w.-]+\.js/, `${d}: loads a shared file from openvibe.network`);
+ for (const m of html.matchAll(/src="\/shared\/([\w.-]+\.js)\?v=([0-9a-f]{12})"/g)) {
+ assert.strictEqual(m[2], require('openvibe-shared/serve').hashOf(m[1]), `${d}: /shared/${m[1]} carries its content hash`);
+ assert.strictEqual(read(`dist/_shared/${m[1]}`), fs.readFileSync(require('openvibe-shared/files').path(m[1]), 'utf8'), `${d}: dist/_shared/${m[1]} is the pinned file`);
+ }
+ assert.match(html, /src="\/shared\/navbar\.js\?v=/, `${d}: the navbar from /shared`);
+ assert.match(read(`deploy/nginx/${d}.conf`), /location \^~ \/shared\/ \{\s+alias \/opt\/openvibe\.sites\/dist\/_shared\/;/, `${d}: nginx serves /shared/ from dist/_shared/`);
assert.ok(html.includes(`data-ov-shipped="latest" data-service="${site.tld}"`), `${d}: the footer's shipped line`);
assert.ok(html.includes(`data-ov-shipped="latest" data-service="${site.tld}"`), `${d}: the footer's shipped line`);
if (site.kind) {
if (site.kind) {
// A notice (a closed product, a pointer) is not indexed, has no sitemap and is not listed as opening.
// A notice (a closed product, a pointer) is not indexed, has no sitemap and is not listed as opening.