openvibers/openvibe.community Commits
vendor: sync openvibe-shared (icons, island, ui, seo, navbar launcher)
Changes
@@@ -308,7 +308,14 @@}}++ /** Drop the server-held cookies (ov_sso is httpOnly, so only the server can). Best effort. */++ function serverLogout() {++ try { fetch(`${_config.apiBase}/api/auth/logout`, { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch { /* */ }++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }++ }++function logout() {++ serverLogout();const activeId = getActiveId();if (activeId) removeAccount(activeId);localStorage.removeItem(TOKEN_KEY);@@@ -319,6 +326,7 @@}function logoutAll() {++ serverLogout();localStorage.removeItem(STORAGE_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(ACTIVE_KEY);@@@ -91,6 +91,9 @@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]+)$/);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);@@@ -38,6 +38,10 @@// 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;@@@ -57,7 +61,28 @@color: var(--text-primary, #e0e0e0);}.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }-- .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }++ .openvibe-navbar-brand a { color: inherit; text-decoration: none; }++ .openvibe-navbar-brand a.b-core:hover, .openvibe-navbar-brand a.b-tld:hover, .openvibe-navbar-brand a.b-sub:hover { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }++ .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 6px; }++ .ovnav-launch { appearance: none; border: 0; background: transparent; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 9px; display: inline-grid; place-items: center; cursor: pointer; flex: none; margin-right: 6px; transition: background .15s, color .15s; }++ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }++ .ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }++ .ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }++ .ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }++ .ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }++ .ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }++ .ovnav-launcher .ovl-sites { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }++ .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; }++ .ovnav-launcher .ovl-site, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }++ .ovnav-launcher .ovl-site { flex-direction: column; text-align: center; gap: 6px; padding: 12px 6px; }++ .ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }++ .ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }++ .ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }++ @media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }++ @media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }++ .openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */.openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }@@@ -480,19 +505,117 @@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 seg = (cls, text) => `<span class="${cls}">${escapeAttr(text)}</span>`;++ 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) + dot + seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText)-- : seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText);++ ? 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 `<a class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}" href="${escapeAttr(brand.href)}" title="${escapeAttr(brand.name)}" aria-label="${escapeAttr(brand.name)}">-- <span class="flame">${mark}</span>++ 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>-- </a>`;++ </div>++ ${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;++ }++++ // ── Network launcher ─────────────────────────────────────++ const LAUNCHER_SITES = [++ { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },++ { name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },++ { name: 'Community', desc: 'Pastes and posts', icon: 'community', url: 'https://openvibe.community/' },++ { name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },++ { name: 'Media', desc: 'VODs, clips, files', icon: 'media', url: 'https://openvibe.media/' },++ { name: 'Network', desc: 'Account and themes', icon: 'network', url: 'https://openvibe.network/' },++ ];++ const LAUNCHER_FAMILIES = [++ { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },++ { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },++ { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },++ { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },++ { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },++ { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },++ { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },++ ];++ const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';++ const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };++++ async function launcherCatalog() {++ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }++ try {++ const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;++ const j = await r.json();++ const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),++ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };++ try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }++ return data;++ } catch { return null; }++ }++++ function bindLauncher(nav) {++ const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;++ let panel = null;++ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;++ const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };++ function paint(cat, q) {++ const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);++ const body = panel.querySelector('.ovl-body');++ if (q && cat) {++ const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 12);++ body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';++ return;++ }++ body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${LAUNCHER_SITES.map(x => tile(x, 'ovl-site')).join('')}</div>++ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>`;++ }++ btn.addEventListener('click', async (e) => {++ e.stopPropagation();++ if (panel && panel.classList.contains('open')) return close();++ if (!panel) {++ panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');++ panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div>';++ nav.appendChild(panel);++ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }++ let cat = null; paint(null, '');++ const input = panel.querySelector('.ovl-q');++ input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));++ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });++ panel.addEventListener('keydown', (ev) => {++ if (ev.key === 'Escape') { close(); btn.focus(); return; }++ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;++ const links = [...panel.querySelectorAll('.ovl-body a')]; const i = links.indexOf(document.activeElement); if (i < 0) return;++ ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();++ });++ panel.addEventListener('click', (ev) => ev.stopPropagation());++ document.addEventListener('click', close);++ launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });++ }++ panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');++ try { panel.querySelector('.ovl-q').focus({ preventScroll: true }); } catch { /* */ }++ });++ }++++ // ── Notification bell (mounted by the navbar itself) ─────++ function mountBell(nav, u) {++ if (_config.notifications === false || !u || u.is_anon || !_config.token) return;++ const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;++ const go = () => {++ const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;++ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network' }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }++ };++ if (root.OpenVibeNotifications) return go();++ if (document.getElementById('ov-notify-loader')) return;++ const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);}function menuItemHTML(item) {@@@ -790,15 +913,84 @@* never for bots. The site's login route turns silent=1 into prompt=none and comes straight* back on error=login_required, so a signed-out visitor sees one quick redirect at most.*/++ let _ssoClientLoading = null;++ function loadSsoClient() {++ if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);++ if (_ssoClientLoading) return _ssoClientLoading;++ _ssoClientLoading = new Promise((resolve) => {++ const sc = document.createElement('script'); sc.async = true; sc.src = `${_config.apiBase}/shared/sso-client.js`;++ sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);++ document.head.appendChild(sc);++ });++ return _ssoClientLoading;++ }++++ /** Signed in here: cross-site links carry the session along (see sso-client.js). */++ function enableHandoff() {++ loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!_config.user && !_config.user.is_anon }); } catch { /* */ } });++ }++++ function silentLoginNow() {++ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));++ location.replace(url);++ return true;++ }++++ /**++ * Two ways to find out that this browser is signed in to the network without a session here:++ * 1. the hint cookie this site set on an earlier sign-in ('account') — go straight to the++ * silent sign-in (one quick redirect, back where you were);++ * 2. otherwise ask the network in a hidden iframe (GET /sso/check) — invisible, no++ * redirect unless the answer is yes. Browsers that partition third-party cookies answer++ * no and nothing happens, which is the same as before.++ * At most once per tab per 10 minutes; never after an explicit sign-out ('guest'); never for bots.++ */function maybeSilentLogin() {if (!_config.silentLogin || typeof location === 'undefined') return false;-- if (ssoHint() !== 'account') return false;++ const hint = ssoHint();++ if (hint === 'guest') return false;if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;-- try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }if (/[?&]sso=none\b/.test(location.search)) return false;-- const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));-- location.replace(url);-- return true;++ 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() {@@@ -827,6 +1019,7 @@if (session.token) _config.token = session.token;render();recordHistory();++ enableHandoff();} else {maybeSilentLogin();}@@@ -939,6 +1132,9 @@`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}</div>`;++ bindLauncher(nav);++ // Pages that wire the bell themselves run right after init(); give them the first go.++ setTimeout(() => mountBell(nav, u), 0);// Dropdownif (u) {@@@ -1019,6 +1215,8 @@clearAuthState();_config.user = null;_config.token = null;++ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }if (onToolsDomain()) {// The gateway also clears the Domain=.openvibe.tools cookie// and the httpOnly refresh cookie, then sends us back here.@@@ -1060,7 +1258,7 @@// the shared SSO state (ov_token cookie / localStorage / optional// sessionUrl) and the navbar re-renders when it arrives.if (!_config.user) refreshAuthState();-- else recordHistory();++ else { recordHistory(); enableHandoff(); }return el;},@@@ -1093,9 +1291,16 @@if (_navEl && _config.user) render();},++ /** Is this browser signed in to the network? ({ signedIn, username } or null when unknown). */++ checkNetworkSession,++/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */brand() { return resolveBrand(); },++ /** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */++ get activity() { return root.OpenVibeIsland || null; },++ set activity(_v) { /* island.js binds itself; nothing to store */ },++setToken(token) { _config.token = token; },/** Get the bell mount point for OpenVibeNotifications. */nnew file mode 100644@@@ -0,0 +1,184 @@++/**++ * openvibe-shared/ov-icons.js — the OpenVibe animated icon family.++ *++ * <span class="ov-icon" data-icon="youtube" data-size="40"></span>++ * <span class="ov-icon" data-icon="pdf" data-state="busy" data-progress="0.4" data-fx="orbit"></span>++ *++ * One construction for every icon so the network reads as one brand: the OV ring (with its comet)++ * around a glyph drawn in the theme accent. The ring is also a progress meter (data-progress 0–1)++ * and a status light (data-state idle|busy|ok|error), so the same icon that names a tool can show++ * what the tool is doing. Glyphs are 24×24 stroke paths centred in the ring.++ *++ * OpenVibeIcons.names() every registered icon++ * OpenVibeIcons.register(name, { glyph, fx?, accent? })++ * OpenVibeIcons.mount(root?) (re)scan; new nodes are picked up automatically++ * OpenVibeIcons.set(el, { progress, state, icon })++ * OpenVibeIcons.svg(name, size) markup string, for server-side / templates++ *++ * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is transform/opacity/++ * stroke only and stops under prefers-reduced-motion. Unknown names fall back to the OV mark "V".++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeIcons) return;++++ const P = (d) => `<path d="${d}"/>`;++ // Glyphs: stroke-only, 24×24 box, designed to sit inside the ring.++ const GLYPHS = {++ ov: P('M6.5 7.5 12 17.5l5.5-10'),++ network: '<circle cx="12" cy="6" r="2"/><circle cx="5.5" cy="17" r="2"/><circle cx="18.5" cy="17" r="2"/>' + P('M12 8v3.5M12 11.5 6.8 15.4M12 11.5l5.2 3.9'),++ live: '<circle cx="12" cy="12" r="2.2"/>' + P('M7.5 7.5a6.4 6.4 0 0 0 0 9M16.5 7.5a6.4 6.4 0 0 1 0 9M4.8 5a10 10 0 0 0 0 14M19.2 5a10 10 0 0 1 0 14'),++ tools: P('M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z'),++ media: '<rect x="4" y="6" width="16" height="12" rx="2"/>' + P('M10 9.5v5l4.5-2.5z'),++ games: P('M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.5.8L15.5 16h-7l-1.3 1.3a2 2 0 0 1-3.5-.8l-.6-2.6A4 4 0 0 1 7 9zM8 11.5v3M6.5 13h3') + '<circle cx="15.5" cy="12" r=".6"/><circle cx="17.5" cy="14" r=".6"/>',++ community: '<circle cx="9" cy="9" r="2.6"/><circle cx="16.5" cy="10" r="2"/>' + P('M3.8 18.5a5.2 5.2 0 0 1 10.4 0M14.6 14.2a4 4 0 0 1 5.6 3.8'),++ chat: P('M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 19 16.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5zM8 10.5h8M8 13h5'),++ codes: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),++ code: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),++ blog: P('M5 19l1.2-4.2L16 5l3 3-9.8 9.8zM14 7l3 3'),++ wiki: P('M5 5.5h6a2 2 0 0 1 2 2V19a2 2 0 0 0-2-2H5zM19 5.5h-4a2 2 0 0 0-2 2V19a2 2 0 0 1 2-2h4z'),++ news: '<rect x="4" y="5.5" width="13" height="13" rx="1.5"/>' + P('M17 9h3v8a1.5 1.5 0 0 1-3 0M7 9h7M7 12h7M7 15h4'),++ reviews: P('M12 4.5l2.2 4.6 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L4.8 9.8l5-.7z'),++ tips: P('M12 19s-6.5-3.8-6.5-8.6A3.6 3.6 0 0 1 12 8a3.6 3.6 0 0 1 6.5 2.4C18.5 15.2 12 19 12 19z'),++ vip: P('M4.5 16.5 6 8l3.8 3.5L12 6.5l2.2 5L18 8l1.5 8.5zM5 19h14'),++ trade: P('M4.5 18.5h15M6.5 15l3.5-4 3 2.5 4.5-6M17.5 7.5h-3M17.5 7.5v3'),++ host: '<rect x="4.5" y="5" width="15" height="5.5" rx="1.2"/><rect x="4.5" y="13.5" width="15" height="5.5" rx="1.2"/><circle cx="7.5" cy="7.75" r=".6"/><circle cx="7.5" cy="16.25" r=".6"/>',++ deals: P('M4.5 12.5V5.5h7l8 8-7 7zM8.2 9.2h.01'),++ coupons: P('M4 8.5h16v2.5a1.5 1.5 0 0 0 0 3v2.5H4V14a1.5 1.5 0 0 0 0-3zM10 8.5v8') ,++ stream: P('M5 17.5 12 6l7 11.5M8 13h8') + '<circle cx="12" cy="6" r="1.4"/>',++ youtube: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),++ video: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),++ download: P('M12 4.5v10M8 11l4 4 4-4M5 19h14'),++ upload: P('M12 15.5v-10M8 9l4-4 4 4M5 19h14'),++ image: '<rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/>' + P('M4.5 17l4.5-4.5 3.5 3.5 2.5-2.5 4.5 4.5'),++ audio: P('M9 17V7.5l9-2V15') + '<circle cx="6.8" cy="17" r="2.2"/><circle cx="15.8" cy="15" r="2.2"/>',++ pdf: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),++ docs: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),++ text: P('M5.5 7V5.5h13V7M12 5.5v13M9.5 18.5h5'),++ logo: P('M12 4.5l1.8 4.2 4.2 1.8-4.2 1.8L12 16.5l-1.8-4.2L6 10.5l4.2-1.8zM18 15.5l.8 1.7 1.7.8-1.7.8-.8 1.7-.8-1.7-1.7-.8 1.7-.8z'),++ json: P('M9 5.5c-2 0-2 1.5-2 3s0 3.5-2 3.5c2 0 2 2 2 3.5s0 3 2 3M15 5.5c2 0 2 1.5 2 3s0 3.5 2 3.5c-2 0-2 2-2 3.5s0 3-2 3'),++ dns: '<circle cx="12" cy="12" r="7.5"/>' + P('M4.5 12h15M12 4.5c2.5 2 2.5 13 0 15M12 4.5c-2.5 2-2.5 13 0 15'),++ ip: P('M12 20s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z') + '<circle cx="12" cy="10" r="2.2"/>',++ ssl: '<rect x="6" y="11" width="12" height="8.5" rx="1.8"/>' + P('M8.5 11V8.5a3.5 3.5 0 0 1 7 0V11M12 14.5v2'),++ ping: P('M3.5 12h4l2-5 3.5 10 2.5-7 1.5 2h3.5'),++ whois: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5M9 10a2 2 0 0 1 4 0c0 1.5-2 1.6-2 3M11 15h.01'),++ search: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5'),++ map: P('M4.5 7l5-2 5 2 5-2v12l-5 2-5-2-5 2zM9.5 5v12M14.5 7v12'),++ food: P('M7 4.5v6a2 2 0 0 0 4 0v-6M9 4.5v15M16.5 4.5c-2 1.5-2.5 4-2.5 6.5h2.5zM16.5 11v8.5'),++ paste: '<rect x="6" y="6" width="12" height="14" rx="1.8"/>' + P('M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6M9 17h3'),++ account: '<circle cx="12" cy="9" r="3.2"/>' + P('M5.5 19a6.5 6.5 0 0 1 13 0'),++ bell: P('M6.5 16.5h11l-1.2-2V11a4.3 4.3 0 0 0-8.6 0v3.5zM10.5 19a1.6 1.6 0 0 0 3 0'),++ history: '<circle cx="12" cy="12" r="7.5"/>' + P('M12 8v4.2l2.8 1.8'),++ theme: P('M12 4.5a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.3h1.6a3.2 3.2 0 0 0 3.2-3.2C19.5 8 16.2 4.5 12 4.5z') + '<circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',++ check: P('M6 12.5l4 4 8-9'),++ error: P('M12 7v6M12 16.5h.01'),++ clip: P('M6 6l12 12M6 18L18 6') ,++ };++ const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell' };++ const registry = Object.create(null);++ for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };++++ const CSS = `++.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}++.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}++.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}++.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}++.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}++.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}++.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-origin:24px 24px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}++.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}++.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}++.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}++.ov-icon[data-fx=none] .ovi-comet{display:none}++.ov-icon[data-fx=pulse] .ovi-comet{display:none}++.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}++.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}++.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}++.ov-icon[data-progress] .ovi-prog{opacity:1}++.ov-icon[data-progress] .ovi-comet{opacity:0}++.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}++.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}++.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}++.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}++.ov-icon[data-state=ok] .ovi-comet,.ov-icon[data-state=error] .ovi-comet{display:none}++.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}++.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}++.ov-icon[data-state=error] svg{animation:oviShake .4s ease}++@keyframes oviSpin{to{transform:rotate(360deg)}}++@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}++@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}++@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}++@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}++@keyframes oviShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6%)}75%{transform:translateX(6%)}}++@media (prefers-reduced-motion:reduce){.ov-icon *, .ov-icon svg{animation:none!important;transition:none!important}.ov-icon .ovi-comet{display:none}}`;++++ function resolve(name) {++ const n = String(name || 'ov').toLowerCase();++ return registry[n] ? n : (registry[ALIASES[n]] ? ALIASES[n] : 'ov');++ }++++ /** Markup for one icon (usable server-side: no DOM needed). Glyph is scaled 24→22 and centred in the 48 box. */++ function svg(name, size) {++ const def = registry[resolve(name)];++ const s = size ? ` width="${size}" height="${size}"` : '';++ return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><circle class="ovi-prog" cx="24" cy="24" r="20"/><g class="ovi-glyph" transform="translate(13 13) scale(.9167)">${def.glyph}</g></svg>`;++ }++++ function applyProgress(el) {++ const raw = el.getAttribute('data-progress');++ const prog = el.querySelector('.ovi-prog');++ if (!prog) return;++ if (raw == null || raw === '') { prog.style.strokeDashoffset = ''; return; }++ const p = Math.max(0, Math.min(1, parseFloat(raw) || 0));++ prog.style.strokeDashoffset = String(125.7 * (1 - p));++ }++++ function mountOne(el) {++ const name = resolve(el.getAttribute('data-icon'));++ if (el.getAttribute('data-ovi') === name) { applyProgress(el); return; }++ el.setAttribute('data-ovi', name);++ const size = parseInt(el.getAttribute('data-size'), 10);++ if (size) el.style.setProperty('--ovi-size', size + 'px');++ const def = registry[name];++ if (def.accent) el.style.setProperty('--ovi-accent', def.accent);++ if (def.fx && !el.hasAttribute('data-fx')) el.setAttribute('data-fx', def.fx);++ el.innerHTML = svg(name);++ if (!el.hasAttribute('role')) el.setAttribute('aria-hidden', 'true');++ applyProgress(el);++ }++++ function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }++++ function set(el, patch) {++ if (!el || !patch) return;++ if (patch.icon) el.setAttribute('data-icon', patch.icon);++ if ('state' in patch) { if (patch.state) el.setAttribute('data-state', patch.state); else el.removeAttribute('data-state'); }++ if ('progress' in patch) { if (patch.progress == null) el.removeAttribute('data-progress'); else el.setAttribute('data-progress', String(patch.progress)); }++ mountOne(el);++ }++++ function register(name, def) {++ if (!name || !def || !def.glyph) return;++ registry[String(name).toLowerCase()] = { glyph: def.glyph, fx: def.fx || null, accent: def.accent || null };++ if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });++ }++++ const api = { names: () => Object.keys(registry), register, mount, set, svg, resolve, CSS };++ root.OpenVibeIcons = api;++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++ if (typeof document === 'undefined') return;++++ function init() {++ if (!document.getElementById('ov-icons-css')) { const st = document.createElement('style'); st.id = 'ov-icons-css'; st.textContent = CSS; document.head.appendChild(st); }++ mount();++ try {++ new MutationObserver((muts) => {++ for (const m of muts) {++ if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; }++ m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-icon')) mountOne(n); else if (n.querySelector) n.querySelectorAll('.ov-icon').forEach(mountOne); });++ }++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });++ } catch { /* */ }++ }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();++})(typeof window !== 'undefined' ? window : globalThis);@@@ -48,6 +48,13 @@.ov-mark:hover .c{animation-duration:.9s}.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}.ov-mark[data-static] .o{display:none}++.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}++.ov-mark[data-progress] .p{opacity:1}++.ov-mark[data-progress] .c,.ov-mark[data-progress] .o{opacity:.25}++.ov-mark[data-state=busy] .c{animation-duration:1.1s}++.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}++.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}++.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}@keyframes ovmComet{to{transform:rotate(360deg)}}@@@ -61,6 +68,7 @@<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>`;@@@ -11,7 +11,9 @@"./brand": "./brand.js","./middleware": "./middleware.js","./notifications": "./notifications.js",-- "./url-resolver": "./url-resolver.js"++ "./url-resolver": "./url-resolver.js",++ "./seo": "./seo.js",++ "./icons": "./ov-icons.js"},"license": "MIT","dependencies": {nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++/**++ * openvibe-shared/seo.js — one SEO toolkit for every OpenVibe site (Node, no dependencies).++ *++ * const seo = require('openvibe-shared/seo');++ * seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })++ * seo.sitemapXml([{ loc, lastmod }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})++ *++ * Rules it encodes so pages do not have to remember them: one canonical per page, titles cut at++ * 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD++ * serialised safely (no `</script>` break-outs), sitemaps with absolute URLs only, robots.txt that++ * welcomes search and AI crawlers by default and always names the sitemap, and an /llms.txt so++ * language-model crawlers get a clean map of the site.++ */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++/** Cut on a word boundary with an ellipsis; never mid-word, never longer than `max`. */++function clip(text, max) {++ const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();++ if (t.length <= max) return t;++ const cut = t.slice(0, max - 1);++ const at = cut.lastIndexOf(' ');++ return (at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.\-–—]+$/, '') + '…';++}++++function absolute(url, base) {++ if (!url) return '';++ try { return new URL(url, base || undefined).toString(); } catch { return String(url); }++}++++/** JSON-LD script tag; `<` is escaped so content can never close the script element. */++function jsonLdTag(obj) {++ return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;++}++++/**++ * The whole <head> SEO block as an HTML string.++ * @param {object} o title, description, canonical (absolute), image, imageAlt, type ('website'),++ * siteName, robots ('index, follow'), locale ('en_US'), keywords (array|string), author,++ * themeColor, twitterSite, alternates [{ hreflang, href }], prev, next, jsonLd (array|object),++ * titleSuffix (appended when it fits), noTitle (skip <title> when the page has its own)++ */++function headTags(o = {}) {++ let title = String(o.title || o.siteName || 'OpenVibe');++ if (o.titleSuffix && (title + o.titleSuffix).length <= 60) title += o.titleSuffix;++ title = clip(title, 60);++ const description = clip(o.description || '', 160);++ const canonical = absolute(o.canonical);++ const image = o.image ? absolute(o.image, canonical) : '';++ const keywords = Array.isArray(o.keywords) ? o.keywords.join(', ') : (o.keywords || '');++ const robots = o.robots || 'index, follow, max-image-preview:large, max-snippet:-1';++ const out = [];++ if (!o.noTitle) out.push(`<title>${esc(title)}</title>`);++ if (description) out.push(`<meta name="description" content="${esc(description)}">`);++ if (keywords) out.push(`<meta name="keywords" content="${esc(keywords)}">`);++ out.push(`<meta name="robots" content="${esc(robots)}">`);++ if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);++ if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);++ if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);++ for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);++ if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);++ if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);++ out.push(`<meta property="og:type" content="${esc(o.type || 'website')}">`);++ if (o.siteName) out.push(`<meta property="og:site_name" content="${esc(o.siteName)}">`);++ out.push(`<meta property="og:title" content="${esc(title)}">`);++ if (description) out.push(`<meta property="og:description" content="${esc(description)}">`);++ if (canonical) out.push(`<meta property="og:url" content="${esc(canonical)}">`);++ out.push(`<meta property="og:locale" content="${esc(o.locale || 'en_US')}">`);++ if (image) {++ out.push(`<meta property="og:image" content="${esc(image)}">`);++ if (o.imageAlt) out.push(`<meta property="og:image:alt" content="${esc(o.imageAlt)}">`);++ }++ out.push(`<meta name="twitter:card" content="${image && o.largeImage !== false ? 'summary_large_image' : 'summary'}">`);++ if (o.twitterSite) out.push(`<meta name="twitter:site" content="${esc(o.twitterSite)}">`);++ out.push(`<meta name="twitter:title" content="${esc(title)}">`);++ if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);++ if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);++ const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]) : [];++ for (const item of ld) if (item) out.push(jsonLdTag(item));++ return out.join('\n');++}++++const ORG = { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png', sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj'] };++++const jsonLd = {++ organization(extra = {}) { return { '@context': 'https://schema.org', ...ORG, ...extra }; },++ /** WebSite; `searchUrl` with {q} adds the sitelinks SearchAction. */++ website({ name, url, description, searchUrl, inLanguage = 'en' } = {}) {++ const o = { '@context': 'https://schema.org', '@type': 'WebSite', '@id': `${url}#site`, name, url, description, inLanguage, publisher: { '@id': ORG['@id'] } };++ if (searchUrl) o.potentialAction = { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: searchUrl.replace('{q}', '{search_term_string}') }, 'query-input': 'required name=search_term_string' };++ return o;++ },++ webPage({ name, url, description, type = 'WebPage', siteUrl, dateModified } = {}) {++ return { '@context': 'https://schema.org', '@type': type, '@id': `${url}#page`, name, url, description, ...(siteUrl ? { isPartOf: { '@id': `${siteUrl}#site` } } : {}), ...(dateModified ? { dateModified } : {}) };++ },++ /** A web tool. No price/offer fields: the platform makes no cost claims. */++ softwareApp({ name, url, description, category = 'UtilitiesApplication', keywords, image, os = 'Any (web browser)' } = {}) {++ return { '@context': 'https://schema.org', '@type': 'WebApplication', name, url, description, applicationCategory: category, operatingSystem: os, browserRequirements: 'Requires a modern web browser', ...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };++ },++ breadcrumbs(items = []) {++ return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) };++ },++ itemList(name, items = [], { url } = {}) {++ return { '@context': 'https://schema.org', '@type': 'ItemList', name, ...(url ? { url } : {}), numberOfItems: items.length, itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, url: it.url, ...(it.description ? { description: it.description } : {}) })) };++ },++ faq(pairs = []) {++ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pairs.map((p) => ({ '@type': 'Question', name: p.q, acceptedAnswer: { '@type': 'Answer', text: p.a } })) };++ },++ howTo({ name, description, steps = [] } = {}) {++ return { '@context': 'https://schema.org', '@type': 'HowTo', name, description, step: steps.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name || `Step ${i + 1}`, text: s.text })) };++ },++ article({ headline, url, description, author, datePublished, dateModified, image } = {}) {++ return { '@context': 'https://schema.org', '@type': 'Article', headline: clip(headline, 110), url, description, ...(author ? { author: { '@type': 'Person', name: author } } : {}), ...(datePublished ? { datePublished } : {}), ...(dateModified ? { dateModified } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };++ },++ video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {++ return { '@context': 'https://schema.org', '@type': 'VideoObject', name, url, description: description || name, thumbnailUrl, uploadDate, ...(duration ? { duration } : {}), ...(contentUrl ? { contentUrl } : {}), ...(embedUrl ? { embedUrl } : {}) };++ },++};++++/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */++function sitemapXml(urls = []) {++ const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {++ const parts = [`<loc>${xml(u.loc)}</loc>`];++ if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);++ if (u.changefreq) parts.push(`<changefreq>${xml(u.changefreq)}</changefreq>`);++ if (u.priority != null) parts.push(`<priority>${Number(u.priority).toFixed(1)}</priority>`);++ for (const a of u.alternates || []) parts.push(`<xhtml:link rel="alternate" hreflang="${xml(a.hreflang)}" href="${xml(a.href)}"/>`);++ for (const img of u.images || []) parts.push(`<image:image><image:loc>${xml(img)}</image:loc></image:image>`);++ return ` <url>${parts.join('')}</url>`;++ });++ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${rows.join('\n')}\n</urlset>\n`;++}++++function sitemapIndexXml(maps = []) {++ const rows = maps.filter((m) => m && m.loc).map((m) => ` <sitemap><loc>${xml(m.loc)}</loc>${m.lastmod ? `<lastmod>${xml(String(m.lastmod).slice(0, 10))}</lastmod>` : ''}</sitemap>`);++ return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;++}++++// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.++const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];++++function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {++ const lines = ['User-agent: *'];++ for (const a of allow) lines.push(`Allow: ${a}`);++ for (const d of disallow) lines.push(`Disallow: ${d}`);++ if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);++ if (allowAI) {++ lines.push('');++ for (const bot of AI_AND_SEARCH_BOTS) lines.push(`User-agent: ${bot}`);++ lines.push('Allow: /');++ for (const d of disallow) lines.push(`Disallow: ${d}`);++ }++ for (const b of block) { lines.push('', `User-agent: ${b}`, 'Disallow: /'); }++ lines.push('');++ for (const s of sitemaps) lines.push(`Sitemap: ${s}`);++ return lines.join('\n') + '\n';++}++++/** /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. */++function llmsTxt({ name, summary, details, sections = [] } = {}) {++ const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];++ if (details) out.push(String(details).trim(), '');++ for (const sec of sections) {++ if (!sec || !sec.links || !sec.links.length) continue;++ out.push(`## ${sec.title}`, '');++ for (const l of sec.links) out.push(`- [${l.title}](${l.url})${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);++ out.push('');++ }++ return out.join('\n');++}++++module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, clip, esc, absolute, AI_AND_SEARCH_BOTS };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,124 @@++/**++ * openvibe-shared/ui.js — one toast / confirm / alert implementation for every OpenVibe site.++ *++ * OpenVibeUI.toast('Saved', { type: 'success' })++ * OpenVibeUI.toast('Upload failed', { type: 'error', title: 'Images', action: { label: 'Retry', onClick } })++ * if (await OpenVibeUI.confirm({ title: 'Delete paste?', message: 'This cannot be undone.', danger: true })) …++ * await OpenVibeUI.alert({ title: 'Heads up', message: '…' })++ *++ * Themed from the site's CSS variables, stacked bottom-right (bottom-centre on phones), announced++ * to assistive tech (role=status, role=alert for errors), pause-on-hover, Escape closes dialogs,++ * focus is trapped in dialogs and restored afterwards. Text is always set with textContent.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeUI) return;++ if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, confirm: async () => false, alert: async () => {} }; if (typeof module !== 'undefined') module.exports = noop; return; }++++ const CSS = `++.ovui-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;max-width:min(380px,calc(100vw - 24px));pointer-events:none}++.ovui-toast{pointer-events:auto;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;padding:11px 12px;border-radius:12px;background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));box-shadow:0 10px 30px rgba(0,0,0,.35);font:500 13.5px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1);border-left:3px solid var(--ovui-c,var(--accent,#3b82f6))}++.ovui-toast[data-type=success]{--ovui-c:var(--success,#22c55e)}++.ovui-toast[data-type=error]{--ovui-c:var(--danger,#ef4444)}++.ovui-toast[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}++.ovui-toast.is-out{animation:ovuiOut .2s ease forwards}++.ovui-dot{width:8px;height:8px;border-radius:50%;background:var(--ovui-c,var(--accent,#3b82f6));margin-top:6px}++.ovui-title{font-weight:700;margin-bottom:1px}++.ovui-msg{color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere}++.ovui-title+.ovui-msg{font-weight:400}++.ovui-toast .ovui-msg:first-child{color:inherit}++.ovui-x,.ovui-act{appearance:none;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:8px}++.ovui-x{opacity:.55;padding:2px 6px;line-height:1;font-size:16px}++.ovui-x:hover{opacity:1}++.ovui-act{margin-top:6px;padding:4px 10px;font-weight:700;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);text-decoration:none;display:inline-block}++.ovui-back{position:fixed;inset:0;z-index:2147483100;background:rgba(3,6,12,.6);backdrop-filter:blur(3px);display:grid;place-items:center;padding:16px;animation:ovuiFade .15s ease}++.ovui-dialog{width:min(420px,100%);background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:16px;padding:20px;box-shadow:0 24px 60px rgba(0,0,0,.5);font:400 14.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .22s cubic-bezier(.2,1.2,.3,1)}++.ovui-dialog h2{margin:0 0 6px;font-size:17px;font-weight:700}++.ovui-dialog p{margin:0;color:var(--text-secondary,#a8b3c4);white-space:pre-wrap;overflow-wrap:anywhere}++.ovui-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}++.ovui-btn{appearance:none;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:600 14px/1 system-ui,sans-serif;padding:10px 16px;border-radius:10px;cursor:pointer}++.ovui-btn:hover{background:rgba(127,127,127,.12)}++.ovui-btn.is-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)}++.ovui-btn.is-danger{background:var(--danger,#ef4444);border-color:transparent;color:#fff}++.ovui-btn:focus-visible,.ovui-x:focus-visible,.ovui-act:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}++@keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}++@keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}}++@keyframes ovuiFade{from{opacity:0}to{opacity:1}}++@media (max-width:560px){.ovui-toasts{left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));max-width:none}}++@media (prefers-reduced-motion:reduce){.ovui-toast,.ovui-dialog,.ovui-back{animation:none!important}}`;++++ function css() { if (document.getElementById('ovui-css')) return; const s = document.createElement('style'); s.id = 'ovui-css'; s.textContent = CSS; document.head.appendChild(s); }++ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; }++ function safeHref(h) { try { const u = new URL(h, location.href); return /^https?:$/.test(u.protocol) ? u.href : null; } catch { return null; } }++++ let stack = null;++ const MAX = 4;++++ function toast(message, opts) {++ const o = opts || {};++ css();++ if (!stack || !stack.isConnected) { stack = el('div', 'ovui-toasts'); stack.setAttribute('aria-live', 'polite'); document.body.appendChild(stack); }++ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';++ const t = el('div', 'ovui-toast'); t.dataset.type = type; t.setAttribute('role', type === 'error' ? 'alert' : 'status');++ t.appendChild(el('span', 'ovui-dot'));++ const body = el('div');++ if (o.title) body.appendChild(el('div', 'ovui-title', o.title));++ body.appendChild(el('div', 'ovui-msg', message));++ if (o.action && o.action.label) {++ const href = o.action.href && safeHref(o.action.href);++ const a = el(href ? 'a' : 'button', 'ovui-act', o.action.label);++ if (href) a.href = href; else a.type = 'button';++ a.addEventListener('click', (ev) => { try { o.action.onClick && o.action.onClick(ev); } finally { close(); } });++ body.appendChild(a);++ }++ t.appendChild(body);++ const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close()); t.appendChild(x);++ stack.appendChild(t);++ while (stack.children.length > MAX) stack.firstChild.remove();++++ const ttl = o.ttl === 0 ? 0 : (o.ttl || (type === 'error' ? 8000 : 4500));++ let timer = null, closed = false;++ const arm = () => { if (ttl) timer = setTimeout(close, ttl); };++ function close() { if (closed) return; closed = true; clearTimeout(timer); t.classList.add('is-out'); setTimeout(() => t.remove(), 220); }++ t.addEventListener('mouseenter', () => clearTimeout(timer)); t.addEventListener('mouseleave', arm);++ arm();++ return { close, el: t };++ }++++ function dialog(o, withCancel) {++ css();++ return new Promise((resolve) => {++ const prev = document.activeElement;++ const back = el('div', 'ovui-back');++ const d = el('div', 'ovui-dialog'); d.setAttribute('role', withCancel ? 'alertdialog' : 'dialog'); d.setAttribute('aria-modal', 'true');++ const h = el('h2', null, o.title || (withCancel ? 'Are you sure?' : 'Notice')); h.id = 'ovui-h-' + Date.now(); d.setAttribute('aria-labelledby', h.id); d.appendChild(h);++ if (o.message) d.appendChild(el('p', null, o.message));++ const btns = el('div', 'ovui-btns');++ let cancel = null;++ if (withCancel) { cancel = el('button', 'ovui-btn', o.cancelLabel || 'Cancel'); cancel.type = 'button'; btns.appendChild(cancel); }++ const ok = el('button', 'ovui-btn ' + (o.danger ? 'is-danger' : 'is-primary'), o.confirmLabel || (withCancel ? 'Confirm' : 'OK')); ok.type = 'button'; btns.appendChild(ok);++ d.appendChild(btns); back.appendChild(d); document.body.appendChild(back);++ function done(v) { document.removeEventListener('keydown', onKey, true); back.remove(); try { prev && prev.focus && prev.focus(); } catch { /* */ } resolve(v); }++ function onKey(e) {++ if (e.key === 'Escape') { e.preventDefault(); done(false); }++ else if (e.key === 'Tab') { const f = [cancel, ok].filter(Boolean); const i = f.indexOf(document.activeElement); e.preventDefault(); f[(i + (e.shiftKey ? f.length - 1 : 1)) % f.length].focus(); }++ }++ document.addEventListener('keydown', onKey, true);++ back.addEventListener('mousedown', (e) => { if (e.target === back) done(false); });++ ok.addEventListener('click', () => done(true));++ if (cancel) cancel.addEventListener('click', () => done(false));++ (o.danger && cancel ? cancel : ok).focus();++ });++ }++++ const api = {++ toast,++ success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })),++ error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })),++ warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })),++ confirm: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), true),++ alert: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), false).then(() => undefined),++ };++ root.OpenVibeUI = api;++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++})(typeof window !== 'undefined' ? window : globalThis);
@@ -308,7 +308,14 @@
@@ -308,7 +308,14 @@
}
}
}
}
+ /** Drop the server-held cookies (ov_sso is httpOnly, so only the server can). Best effort. */
+ function serverLogout() {
+ try { fetch(`${_config.apiBase}/api/auth/logout`, { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch { /* */ }
+ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
+ }
+
function logout() {
function logout() {
+ serverLogout();
const activeId = getActiveId();
const activeId = getActiveId();
if (activeId) removeAccount(activeId);
if (activeId) removeAccount(activeId);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
@@ -319,6 +326,7 @@
@@ -319,6 +326,7 @@
}
}
function logoutAll() {
function logoutAll() {
+ serverLogout();
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ACTIVE_KEY);
localStorage.removeItem(ACTIVE_KEY);
@@ -91,6 +91,9 @@
@@ -91,6 +91,9 @@
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 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). */
/** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */
function brandFor(service) {
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 h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';
const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
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);
@@ -38,6 +38,10 @@
@@ -38,6 +38,10 @@
// but this browser has signed in to the network before (ov_sso_hint), try one silent
// 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.
// prompt=none round trip per tab so a session on one site becomes a session on all.
silentLogin: null,
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: [] };
const _runtimeMenu = { before: [], after: [] };
let _runtimeLinks = null;
let _runtimeLinks = null;
@@ -57,7 +61,28 @@
@@ -57,7 +61,28 @@
color: var(--text-primary, #e0e0e0);
color: var(--text-primary, #e0e0e0);
}
}
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
- .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
+ .openvibe-navbar-brand a { color: inherit; text-decoration: none; }
+ .openvibe-navbar-brand a.b-core:hover, .openvibe-navbar-brand a.b-tld:hover, .openvibe-navbar-brand a.b-sub:hover { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }
+ .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 6px; }
+ .ovnav-launch { appearance: none; border: 0; background: transparent; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 9px; display: inline-grid; place-items: center; cursor: pointer; flex: none; margin-right: 6px; transition: background .15s, color .15s; }
+ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
+ .ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
+ .ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
+ .ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }
+ .ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }
+ .ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }
+ .ovnav-launcher .ovl-sites { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
+ .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; }
+ .ovnav-launcher .ovl-site, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }
+ .ovnav-launcher .ovl-site { flex-direction: column; text-align: center; gap: 6px; padding: 12px 6px; }
+ .ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
+ .ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
+ .ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
+ @media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
+ @media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
+ .openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
.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. */
/* 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-sub { color: var(--text-primary, #e0e0e0); }
@@ -480,19 +505,117 @@
@@ -480,19 +505,117 @@
return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };
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) {
function brandHTML(brand) {
- const seg = (cls, text) => `<span class="${cls}">${escapeAttr(text)}</span>`;
+ 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 dot = '<span class="b-dot">.</span>';
const text = brand.subText
const text = brand.subText
- ? seg('b-sub', brand.subText) + dot + seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText)
- : seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText);
+ ? 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
const mark = brand.icon
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
: `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
- return `<a class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}" href="${escapeAttr(brand.href)}" title="${escapeAttr(brand.name)}" aria-label="${escapeAttr(brand.name)}">
- <span class="flame">${mark}</span>
+ 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>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
- </a>`;
+ </div>
+ ${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
+ }
+
+ // ── Network launcher ─────────────────────────────────────
+ const LAUNCHER_SITES = [
+ { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
+ { name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },
+ { name: 'Community', desc: 'Pastes and posts', icon: 'community', url: 'https://openvibe.community/' },
+ { name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },
+ { name: 'Media', desc: 'VODs, clips, files', icon: 'media', url: 'https://openvibe.media/' },
+ { name: 'Network', desc: 'Account and themes', icon: 'network', url: 'https://openvibe.network/' },
+ ];
+ const LAUNCHER_FAMILIES = [
+ { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },
+ { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },
+ { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },
+ { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },
+ { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },
+ { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
+ { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
+ ];
+ const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';
+ const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
+
+ async function launcherCatalog() {
+ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
+ try {
+ const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
+ const j = await r.json();
+ const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),
+ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
+ try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
+ return data;
+ } catch { return null; }
+ }
+
+ function bindLauncher(nav) {
+ const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
+ let panel = null;
+ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
+ const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
+ function paint(cat, q) {
+ const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
+ const body = panel.querySelector('.ovl-body');
+ if (q && cat) {
+ const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 12);
+ body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
+ return;
+ }
+ body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${LAUNCHER_SITES.map(x => tile(x, 'ovl-site')).join('')}</div>
+ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>`;
+ }
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ if (panel && panel.classList.contains('open')) return close();
+ if (!panel) {
+ panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');
+ panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div>';
+ nav.appendChild(panel);
+ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
+ let cat = null; paint(null, '');
+ const input = panel.querySelector('.ovl-q');
+ input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
+ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });
+ panel.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Escape') { close(); btn.focus(); return; }
+ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
+ const links = [...panel.querySelectorAll('.ovl-body a')]; const i = links.indexOf(document.activeElement); if (i < 0) return;
+ ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();
+ });
+ panel.addEventListener('click', (ev) => ev.stopPropagation());
+ document.addEventListener('click', close);
+ launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });
+ }
+ panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
+ try { panel.querySelector('.ovl-q').focus({ preventScroll: true }); } catch { /* */ }
+ });
+ }
+
+ // ── Notification bell (mounted by the navbar itself) ─────
+ function mountBell(nav, u) {
+ if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
+ const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;
+ const go = () => {
+ const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;
+ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network' }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }
+ };
+ if (root.OpenVibeNotifications) return go();
+ if (document.getElementById('ov-notify-loader')) return;
+ const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
}
}
function menuItemHTML(item) {
function menuItemHTML(item) {
@@ -790,15 +913,84 @@
@@ -790,15 +913,84 @@
* never for bots. The site's login route turns silent=1 into prompt=none and comes straight
* 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.
* back on error=login_required, so a signed-out visitor sees one quick redirect at most.
*/
*/
+ let _ssoClientLoading = null;
+ function loadSsoClient() {
+ if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
+ if (_ssoClientLoading) return _ssoClientLoading;
+ _ssoClientLoading = new Promise((resolve) => {
+ const sc = document.createElement('script'); sc.async = true; sc.src = `${_config.apiBase}/shared/sso-client.js`;
+ sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);
+ document.head.appendChild(sc);
+ });
+ return _ssoClientLoading;
+ }
+
+ /** Signed in here: cross-site links carry the session along (see sso-client.js). */
+ function enableHandoff() {
+ loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!_config.user && !_config.user.is_anon }); } catch { /* */ } });
+ }
+
+ function silentLoginNow() {
+ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
+ location.replace(url);
+ return true;
+ }
+
+ /**
+ * Two ways to find out that this browser is signed in to the network without a session here:
+ * 1. the hint cookie this site set on an earlier sign-in ('account') — go straight to the
+ * silent sign-in (one quick redirect, back where you were);
+ * 2. otherwise ask the network in a hidden iframe (GET /sso/check) — invisible, no
+ * redirect unless the answer is yes. Browsers that partition third-party cookies answer
+ * no and nothing happens, which is the same as before.
+ * At most once per tab per 10 minutes; never after an explicit sign-out ('guest'); never for bots.
+ */
function maybeSilentLogin() {
function maybeSilentLogin() {
if (!_config.silentLogin || typeof location === 'undefined') return false;
if (!_config.silentLogin || typeof location === 'undefined') return false;
- if (ssoHint() !== 'account') return false;
+ const hint = ssoHint();
+ if (hint === 'guest') return false;
if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
- try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }
if (/[?&]sso=none\b/.test(location.search)) return false;
if (/[?&]sso=none\b/.test(location.search)) return false;
- const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
- location.replace(url);
- return true;
+ 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() {
function recordHistory() {
@@ -827,6 +1019,7 @@
@@ -827,6 +1019,7 @@
if (session.token) _config.token = session.token;
if (session.token) _config.token = session.token;
render();
render();
recordHistory();
recordHistory();
+ enableHandoff();
} else {
} else {
maybeSilentLogin();
maybeSilentLogin();
}
}
@@ -939,6 +1132,9 @@
@@ -939,6 +1132,9 @@
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
</div>
</div>
`;
`;
+ bindLauncher(nav);
+ // Pages that wire the bell themselves run right after init(); give them the first go.
+ setTimeout(() => mountBell(nav, u), 0);
// Dropdown
// Dropdown
if (u) {
if (u) {
@@ -1019,6 +1215,8 @@
@@ -1019,6 +1215,8 @@
clearAuthState();
clearAuthState();
_config.user = null;
_config.user = null;
_config.token = null;
_config.token = null;
+ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
+ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
if (onToolsDomain()) {
if (onToolsDomain()) {
// The gateway also clears the Domain=.openvibe.tools cookie
// The gateway also clears the Domain=.openvibe.tools cookie
// and the httpOnly refresh cookie, then sends us back here.
// and the httpOnly refresh cookie, then sends us back here.
@@ -1060,7 +1258,7 @@
@@ -1060,7 +1258,7 @@
// the shared SSO state (ov_token cookie / localStorage / optional
// the shared SSO state (ov_token cookie / localStorage / optional
// sessionUrl) and the navbar re-renders when it arrives.
// sessionUrl) and the navbar re-renders when it arrives.
if (!_config.user) refreshAuthState();
if (!_config.user) refreshAuthState();
- else recordHistory();
+ else { recordHistory(); enableHandoff(); }
return el;
return el;
},
},
@@ -1093,9 +1291,16 @@
@@ -1093,9 +1291,16 @@
if (_navEl && _config.user) render();
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 }). */
/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
brand() { return resolveBrand(); },
brand() { return resolveBrand(); },
+ /** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */
+ get activity() { return root.OpenVibeIsland || null; },
+ set activity(_v) { /* island.js binds itself; nothing to store */ },
+
setToken(token) { _config.token = token; },
setToken(token) { _config.token = token; },
/** Get the bell mount point for OpenVibeNotifications. */
/** Get the bell mount point for OpenVibeNotifications. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+/**
+ * openvibe-shared/ov-icons.js — the OpenVibe animated icon family.
+ *
+ * <span class="ov-icon" data-icon="youtube" data-size="40"></span>
+ * <span class="ov-icon" data-icon="pdf" data-state="busy" data-progress="0.4" data-fx="orbit"></span>
+ *
+ * One construction for every icon so the network reads as one brand: the OV ring (with its comet)
+ * around a glyph drawn in the theme accent. The ring is also a progress meter (data-progress 0–1)
+ * and a status light (data-state idle|busy|ok|error), so the same icon that names a tool can show
+ * what the tool is doing. Glyphs are 24×24 stroke paths centred in the ring.
+ *
+ * OpenVibeIcons.names() every registered icon
+ * OpenVibeIcons.register(name, { glyph, fx?, accent? })
+ * OpenVibeIcons.mount(root?) (re)scan; new nodes are picked up automatically
+ * OpenVibeIcons.set(el, { progress, state, icon })
+ * OpenVibeIcons.svg(name, size) markup string, for server-side / templates
+ *
+ * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is transform/opacity/
+ * stroke only and stops under prefers-reduced-motion. Unknown names fall back to the OV mark "V".
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeIcons) return;
+
+ const P = (d) => `<path d="${d}"/>`;
+ // Glyphs: stroke-only, 24×24 box, designed to sit inside the ring.
+ const GLYPHS = {
+ ov: P('M6.5 7.5 12 17.5l5.5-10'),
+ network: '<circle cx="12" cy="6" r="2"/><circle cx="5.5" cy="17" r="2"/><circle cx="18.5" cy="17" r="2"/>' + P('M12 8v3.5M12 11.5 6.8 15.4M12 11.5l5.2 3.9'),
+ live: '<circle cx="12" cy="12" r="2.2"/>' + P('M7.5 7.5a6.4 6.4 0 0 0 0 9M16.5 7.5a6.4 6.4 0 0 1 0 9M4.8 5a10 10 0 0 0 0 14M19.2 5a10 10 0 0 1 0 14'),
+ tools: P('M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z'),
+ media: '<rect x="4" y="6" width="16" height="12" rx="2"/>' + P('M10 9.5v5l4.5-2.5z'),
+ games: P('M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.5.8L15.5 16h-7l-1.3 1.3a2 2 0 0 1-3.5-.8l-.6-2.6A4 4 0 0 1 7 9zM8 11.5v3M6.5 13h3') + '<circle cx="15.5" cy="12" r=".6"/><circle cx="17.5" cy="14" r=".6"/>',
+ community: '<circle cx="9" cy="9" r="2.6"/><circle cx="16.5" cy="10" r="2"/>' + P('M3.8 18.5a5.2 5.2 0 0 1 10.4 0M14.6 14.2a4 4 0 0 1 5.6 3.8'),
+ chat: P('M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 19 16.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5zM8 10.5h8M8 13h5'),
+ codes: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),
+ code: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),
+ blog: P('M5 19l1.2-4.2L16 5l3 3-9.8 9.8zM14 7l3 3'),
+ wiki: P('M5 5.5h6a2 2 0 0 1 2 2V19a2 2 0 0 0-2-2H5zM19 5.5h-4a2 2 0 0 0-2 2V19a2 2 0 0 1 2-2h4z'),
+ news: '<rect x="4" y="5.5" width="13" height="13" rx="1.5"/>' + P('M17 9h3v8a1.5 1.5 0 0 1-3 0M7 9h7M7 12h7M7 15h4'),
+ reviews: P('M12 4.5l2.2 4.6 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L4.8 9.8l5-.7z'),
+ tips: P('M12 19s-6.5-3.8-6.5-8.6A3.6 3.6 0 0 1 12 8a3.6 3.6 0 0 1 6.5 2.4C18.5 15.2 12 19 12 19z'),
+ vip: P('M4.5 16.5 6 8l3.8 3.5L12 6.5l2.2 5L18 8l1.5 8.5zM5 19h14'),
+ trade: P('M4.5 18.5h15M6.5 15l3.5-4 3 2.5 4.5-6M17.5 7.5h-3M17.5 7.5v3'),
+ host: '<rect x="4.5" y="5" width="15" height="5.5" rx="1.2"/><rect x="4.5" y="13.5" width="15" height="5.5" rx="1.2"/><circle cx="7.5" cy="7.75" r=".6"/><circle cx="7.5" cy="16.25" r=".6"/>',
+ deals: P('M4.5 12.5V5.5h7l8 8-7 7zM8.2 9.2h.01'),
+ coupons: P('M4 8.5h16v2.5a1.5 1.5 0 0 0 0 3v2.5H4V14a1.5 1.5 0 0 0 0-3zM10 8.5v8') ,
+ stream: P('M5 17.5 12 6l7 11.5M8 13h8') + '<circle cx="12" cy="6" r="1.4"/>',
+ youtube: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),
+ video: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),
+ download: P('M12 4.5v10M8 11l4 4 4-4M5 19h14'),
+ upload: P('M12 15.5v-10M8 9l4-4 4 4M5 19h14'),
+ image: '<rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/>' + P('M4.5 17l4.5-4.5 3.5 3.5 2.5-2.5 4.5 4.5'),
+ audio: P('M9 17V7.5l9-2V15') + '<circle cx="6.8" cy="17" r="2.2"/><circle cx="15.8" cy="15" r="2.2"/>',
+ pdf: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),
+ docs: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),
+ text: P('M5.5 7V5.5h13V7M12 5.5v13M9.5 18.5h5'),
+ logo: P('M12 4.5l1.8 4.2 4.2 1.8-4.2 1.8L12 16.5l-1.8-4.2L6 10.5l4.2-1.8zM18 15.5l.8 1.7 1.7.8-1.7.8-.8 1.7-.8-1.7-1.7-.8 1.7-.8z'),
+ json: P('M9 5.5c-2 0-2 1.5-2 3s0 3.5-2 3.5c2 0 2 2 2 3.5s0 3 2 3M15 5.5c2 0 2 1.5 2 3s0 3.5 2 3.5c-2 0-2 2-2 3.5s0 3-2 3'),
+ dns: '<circle cx="12" cy="12" r="7.5"/>' + P('M4.5 12h15M12 4.5c2.5 2 2.5 13 0 15M12 4.5c-2.5 2-2.5 13 0 15'),
+ ip: P('M12 20s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z') + '<circle cx="12" cy="10" r="2.2"/>',
+ ssl: '<rect x="6" y="11" width="12" height="8.5" rx="1.8"/>' + P('M8.5 11V8.5a3.5 3.5 0 0 1 7 0V11M12 14.5v2'),
+ ping: P('M3.5 12h4l2-5 3.5 10 2.5-7 1.5 2h3.5'),
+ whois: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5M9 10a2 2 0 0 1 4 0c0 1.5-2 1.6-2 3M11 15h.01'),
+ search: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5'),
+ map: P('M4.5 7l5-2 5 2 5-2v12l-5 2-5-2-5 2zM9.5 5v12M14.5 7v12'),
+ food: P('M7 4.5v6a2 2 0 0 0 4 0v-6M9 4.5v15M16.5 4.5c-2 1.5-2.5 4-2.5 6.5h2.5zM16.5 11v8.5'),
+ paste: '<rect x="6" y="6" width="12" height="14" rx="1.8"/>' + P('M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6M9 17h3'),
+ account: '<circle cx="12" cy="9" r="3.2"/>' + P('M5.5 19a6.5 6.5 0 0 1 13 0'),
+ bell: P('M6.5 16.5h11l-1.2-2V11a4.3 4.3 0 0 0-8.6 0v3.5zM10.5 19a1.6 1.6 0 0 0 3 0'),
+ history: '<circle cx="12" cy="12" r="7.5"/>' + P('M12 8v4.2l2.8 1.8'),
+ theme: P('M12 4.5a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.3h1.6a3.2 3.2 0 0 0 3.2-3.2C19.5 8 16.2 4.5 12 4.5z') + '<circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',
+ check: P('M6 12.5l4 4 8-9'),
+ error: P('M12 7v6M12 16.5h.01'),
+ clip: P('M6 6l12 12M6 18L18 6') ,
+ };
+ const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell' };
+ const registry = Object.create(null);
+ for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };
+
+ const CSS = `
+.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
+.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}
+.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
+.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
+.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}
+.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
+.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-origin:24px 24px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}
+.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}
+.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}
+.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}
+.ov-icon[data-fx=none] .ovi-comet{display:none}
+.ov-icon[data-fx=pulse] .ovi-comet{display:none}
+.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}
+.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
+.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
+.ov-icon[data-progress] .ovi-prog{opacity:1}
+.ov-icon[data-progress] .ovi-comet{opacity:0}
+.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}
+.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
+.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
+.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
+.ov-icon[data-state=ok] .ovi-comet,.ov-icon[data-state=error] .ovi-comet{display:none}
+.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}
+.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}
+.ov-icon[data-state=error] svg{animation:oviShake .4s ease}
+@keyframes oviSpin{to{transform:rotate(360deg)}}
+@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}
+@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}
+@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}
+@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}
+@keyframes oviShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6%)}75%{transform:translateX(6%)}}
+@media (prefers-reduced-motion:reduce){.ov-icon *, .ov-icon svg{animation:none!important;transition:none!important}.ov-icon .ovi-comet{display:none}}`;
+
+ function resolve(name) {
+ const n = String(name || 'ov').toLowerCase();
+ return registry[n] ? n : (registry[ALIASES[n]] ? ALIASES[n] : 'ov');
+ }
+
+ /** Markup for one icon (usable server-side: no DOM needed). Glyph is scaled 24→22 and centred in the 48 box. */
+ function svg(name, size) {
+ const def = registry[resolve(name)];
+ const s = size ? ` width="${size}" height="${size}"` : '';
+ return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><circle class="ovi-prog" cx="24" cy="24" r="20"/><g class="ovi-glyph" transform="translate(13 13) scale(.9167)">${def.glyph}</g></svg>`;
+ }
+
+ function applyProgress(el) {
+ const raw = el.getAttribute('data-progress');
+ const prog = el.querySelector('.ovi-prog');
+ if (!prog) return;
+ if (raw == null || raw === '') { prog.style.strokeDashoffset = ''; return; }
+ const p = Math.max(0, Math.min(1, parseFloat(raw) || 0));
+ prog.style.strokeDashoffset = String(125.7 * (1 - p));
+ }
+
+ function mountOne(el) {
+ const name = resolve(el.getAttribute('data-icon'));
+ if (el.getAttribute('data-ovi') === name) { applyProgress(el); return; }
+ el.setAttribute('data-ovi', name);
+ const size = parseInt(el.getAttribute('data-size'), 10);
+ if (size) el.style.setProperty('--ovi-size', size + 'px');
+ const def = registry[name];
+ if (def.accent) el.style.setProperty('--ovi-accent', def.accent);
+ if (def.fx && !el.hasAttribute('data-fx')) el.setAttribute('data-fx', def.fx);
+ el.innerHTML = svg(name);
+ if (!el.hasAttribute('role')) el.setAttribute('aria-hidden', 'true');
+ applyProgress(el);
+ }
+
+ function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }
+
+ function set(el, patch) {
+ if (!el || !patch) return;
+ if (patch.icon) el.setAttribute('data-icon', patch.icon);
+ if ('state' in patch) { if (patch.state) el.setAttribute('data-state', patch.state); else el.removeAttribute('data-state'); }
+ if ('progress' in patch) { if (patch.progress == null) el.removeAttribute('data-progress'); else el.setAttribute('data-progress', String(patch.progress)); }
+ mountOne(el);
+ }
+
+ function register(name, def) {
+ if (!name || !def || !def.glyph) return;
+ registry[String(name).toLowerCase()] = { glyph: def.glyph, fx: def.fx || null, accent: def.accent || null };
+ if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });
+ }
+
+ const api = { names: () => Object.keys(registry), register, mount, set, svg, resolve, CSS };
+ root.OpenVibeIcons = api;
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ if (typeof document === 'undefined') return;
+
+ function init() {
+ if (!document.getElementById('ov-icons-css')) { const st = document.createElement('style'); st.id = 'ov-icons-css'; st.textContent = CSS; document.head.appendChild(st); }
+ mount();
+ try {
+ new MutationObserver((muts) => {
+ for (const m of muts) {
+ if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; }
+ m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-icon')) mountOne(n); else if (n.querySelector) n.querySelectorAll('.ov-icon').forEach(mountOne); });
+ }
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });
+ } catch { /* */ }
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -48,6 +48,13 @@
@@ -48,6 +48,13 @@
.ov-mark:hover .c{animation-duration:.9s}
.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] 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-static] .o{display:none}
+.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}
+.ov-mark[data-progress] .p{opacity:1}
+.ov-mark[data-progress] .c,.ov-mark[data-progress] .o{opacity:.25}
+.ov-mark[data-state=busy] .c{animation-duration:1.1s}
+.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}
+.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}
+.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}
@keyframes 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 ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
@keyframes ovmComet{to{transform:rotate(360deg)}}
@keyframes ovmComet{to{transform:rotate(360deg)}}
@@ -61,6 +68,7 @@
@@ -61,6 +68,7 @@
<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>
<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>
<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="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"/>
<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" 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>`;
<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
@@ -11,7 +11,9 @@
@@ -11,7 +11,9 @@
"./brand": "./brand.js",
"./brand": "./brand.js",
"./middleware": "./middleware.js",
"./middleware": "./middleware.js",
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
- "./url-resolver": "./url-resolver.js"
+ "./url-resolver": "./url-resolver.js",
+ "./seo": "./seo.js",
+ "./icons": "./ov-icons.js"
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+/**
+ * openvibe-shared/seo.js — one SEO toolkit for every OpenVibe site (Node, no dependencies).
+ *
+ * const seo = require('openvibe-shared/seo');
+ * seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })
+ * seo.sitemapXml([{ loc, lastmod }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})
+ *
+ * Rules it encodes so pages do not have to remember them: one canonical per page, titles cut at
+ * 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD
+ * serialised safely (no `</script>` break-outs), sitemaps with absolute URLs only, robots.txt that
+ * welcomes search and AI crawlers by default and always names the sitemap, and an /llms.txt so
+ * language-model crawlers get a clean map of the site.
+ */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+/** Cut on a word boundary with an ellipsis; never mid-word, never longer than `max`. */
+function clip(text, max) {
+ const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
+ if (t.length <= max) return t;
+ const cut = t.slice(0, max - 1);
+ const at = cut.lastIndexOf(' ');
+ return (at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.\-–—]+$/, '') + '…';
+}
+
+function absolute(url, base) {
+ if (!url) return '';
+ try { return new URL(url, base || undefined).toString(); } catch { return String(url); }
+}
+
+/** JSON-LD script tag; `<` is escaped so content can never close the script element. */
+function jsonLdTag(obj) {
+ return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
+}
+
+/**
+ * The whole <head> SEO block as an HTML string.
+ * @param {object} o title, description, canonical (absolute), image, imageAlt, type ('website'),
+ * siteName, robots ('index, follow'), locale ('en_US'), keywords (array|string), author,
+ * themeColor, twitterSite, alternates [{ hreflang, href }], prev, next, jsonLd (array|object),
+ * titleSuffix (appended when it fits), noTitle (skip <title> when the page has its own)
+ */
+function headTags(o = {}) {
+ let title = String(o.title || o.siteName || 'OpenVibe');
+ if (o.titleSuffix && (title + o.titleSuffix).length <= 60) title += o.titleSuffix;
+ title = clip(title, 60);
+ const description = clip(o.description || '', 160);
+ const canonical = absolute(o.canonical);
+ const image = o.image ? absolute(o.image, canonical) : '';
+ const keywords = Array.isArray(o.keywords) ? o.keywords.join(', ') : (o.keywords || '');
+ const robots = o.robots || 'index, follow, max-image-preview:large, max-snippet:-1';
+ const out = [];
+ if (!o.noTitle) out.push(`<title>${esc(title)}</title>`);
+ if (description) out.push(`<meta name="description" content="${esc(description)}">`);
+ if (keywords) out.push(`<meta name="keywords" content="${esc(keywords)}">`);
+ out.push(`<meta name="robots" content="${esc(robots)}">`);
+ if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);
+ if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);
+ if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);
+ for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);
+ if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);
+ if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);
+ out.push(`<meta property="og:type" content="${esc(o.type || 'website')}">`);
+ if (o.siteName) out.push(`<meta property="og:site_name" content="${esc(o.siteName)}">`);
+ out.push(`<meta property="og:title" content="${esc(title)}">`);
+ if (description) out.push(`<meta property="og:description" content="${esc(description)}">`);
+ if (canonical) out.push(`<meta property="og:url" content="${esc(canonical)}">`);
+ out.push(`<meta property="og:locale" content="${esc(o.locale || 'en_US')}">`);
+ if (image) {
+ out.push(`<meta property="og:image" content="${esc(image)}">`);
+ if (o.imageAlt) out.push(`<meta property="og:image:alt" content="${esc(o.imageAlt)}">`);
+ }
+ out.push(`<meta name="twitter:card" content="${image && o.largeImage !== false ? 'summary_large_image' : 'summary'}">`);
+ if (o.twitterSite) out.push(`<meta name="twitter:site" content="${esc(o.twitterSite)}">`);
+ out.push(`<meta name="twitter:title" content="${esc(title)}">`);
+ if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);
+ if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);
+ const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]) : [];
+ for (const item of ld) if (item) out.push(jsonLdTag(item));
+ return out.join('\n');
+}
+
+const ORG = { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png', sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj'] };
+
+const jsonLd = {
+ organization(extra = {}) { return { '@context': 'https://schema.org', ...ORG, ...extra }; },
+ /** WebSite; `searchUrl` with {q} adds the sitelinks SearchAction. */
+ website({ name, url, description, searchUrl, inLanguage = 'en' } = {}) {
+ const o = { '@context': 'https://schema.org', '@type': 'WebSite', '@id': `${url}#site`, name, url, description, inLanguage, publisher: { '@id': ORG['@id'] } };
+ if (searchUrl) o.potentialAction = { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: searchUrl.replace('{q}', '{search_term_string}') }, 'query-input': 'required name=search_term_string' };
+ return o;
+ },
+ webPage({ name, url, description, type = 'WebPage', siteUrl, dateModified } = {}) {
+ return { '@context': 'https://schema.org', '@type': type, '@id': `${url}#page`, name, url, description, ...(siteUrl ? { isPartOf: { '@id': `${siteUrl}#site` } } : {}), ...(dateModified ? { dateModified } : {}) };
+ },
+ /** A web tool. No price/offer fields: the platform makes no cost claims. */
+ softwareApp({ name, url, description, category = 'UtilitiesApplication', keywords, image, os = 'Any (web browser)' } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'WebApplication', name, url, description, applicationCategory: category, operatingSystem: os, browserRequirements: 'Requires a modern web browser', ...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
+ },
+ breadcrumbs(items = []) {
+ return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) };
+ },
+ itemList(name, items = [], { url } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'ItemList', name, ...(url ? { url } : {}), numberOfItems: items.length, itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, url: it.url, ...(it.description ? { description: it.description } : {}) })) };
+ },
+ faq(pairs = []) {
+ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pairs.map((p) => ({ '@type': 'Question', name: p.q, acceptedAnswer: { '@type': 'Answer', text: p.a } })) };
+ },
+ howTo({ name, description, steps = [] } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'HowTo', name, description, step: steps.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name || `Step ${i + 1}`, text: s.text })) };
+ },
+ article({ headline, url, description, author, datePublished, dateModified, image } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'Article', headline: clip(headline, 110), url, description, ...(author ? { author: { '@type': 'Person', name: author } } : {}), ...(datePublished ? { datePublished } : {}), ...(dateModified ? { dateModified } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
+ },
+ video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'VideoObject', name, url, description: description || name, thumbnailUrl, uploadDate, ...(duration ? { duration } : {}), ...(contentUrl ? { contentUrl } : {}), ...(embedUrl ? { embedUrl } : {}) };
+ },
+};
+
+/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */
+function sitemapXml(urls = []) {
+ const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {
+ const parts = [`<loc>${xml(u.loc)}</loc>`];
+ if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);
+ if (u.changefreq) parts.push(`<changefreq>${xml(u.changefreq)}</changefreq>`);
+ if (u.priority != null) parts.push(`<priority>${Number(u.priority).toFixed(1)}</priority>`);
+ for (const a of u.alternates || []) parts.push(`<xhtml:link rel="alternate" hreflang="${xml(a.hreflang)}" href="${xml(a.href)}"/>`);
+ for (const img of u.images || []) parts.push(`<image:image><image:loc>${xml(img)}</image:loc></image:image>`);
+ return ` <url>${parts.join('')}</url>`;
+ });
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${rows.join('\n')}\n</urlset>\n`;
+}
+
+function sitemapIndexXml(maps = []) {
+ const rows = maps.filter((m) => m && m.loc).map((m) => ` <sitemap><loc>${xml(m.loc)}</loc>${m.lastmod ? `<lastmod>${xml(String(m.lastmod).slice(0, 10))}</lastmod>` : ''}</sitemap>`);
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;
+}
+
+// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.
+const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];
+
+function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {
+ const lines = ['User-agent: *'];
+ for (const a of allow) lines.push(`Allow: ${a}`);
+ for (const d of disallow) lines.push(`Disallow: ${d}`);
+ if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);
+ if (allowAI) {
+ lines.push('');
+ for (const bot of AI_AND_SEARCH_BOTS) lines.push(`User-agent: ${bot}`);
+ lines.push('Allow: /');
+ for (const d of disallow) lines.push(`Disallow: ${d}`);
+ }
+ for (const b of block) { lines.push('', `User-agent: ${b}`, 'Disallow: /'); }
+ lines.push('');
+ for (const s of sitemaps) lines.push(`Sitemap: ${s}`);
+ return lines.join('\n') + '\n';
+}
+
+/** /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. */
+function llmsTxt({ name, summary, details, sections = [] } = {}) {
+ const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];
+ if (details) out.push(String(details).trim(), '');
+ for (const sec of sections) {
+ if (!sec || !sec.links || !sec.links.length) continue;
+ out.push(`## ${sec.title}`, '');
+ for (const l of sec.links) out.push(`- [${l.title}](${l.url})${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);
+ out.push('');
+ }
+ return out.join('\n');
+}
+
+module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, clip, esc, absolute, AI_AND_SEARCH_BOTS };
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,124 @@
@@ -0,0 +1,124 @@
+/**
+ * openvibe-shared/ui.js — one toast / confirm / alert implementation for every OpenVibe site.
+ *
+ * OpenVibeUI.toast('Saved', { type: 'success' })
+ * OpenVibeUI.toast('Upload failed', { type: 'error', title: 'Images', action: { label: 'Retry', onClick } })
+ * if (await OpenVibeUI.confirm({ title: 'Delete paste?', message: 'This cannot be undone.', danger: true })) …
+ * await OpenVibeUI.alert({ title: 'Heads up', message: '…' })
+ *
+ * Themed from the site's CSS variables, stacked bottom-right (bottom-centre on phones), announced
+ * to assistive tech (role=status, role=alert for errors), pause-on-hover, Escape closes dialogs,
+ * focus is trapped in dialogs and restored afterwards. Text is always set with textContent.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeUI) return;
+ if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, confirm: async () => false, alert: async () => {} }; if (typeof module !== 'undefined') module.exports = noop; return; }
+
+ const CSS = `
+.ovui-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;max-width:min(380px,calc(100vw - 24px));pointer-events:none}
+.ovui-toast{pointer-events:auto;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;padding:11px 12px;border-radius:12px;background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));box-shadow:0 10px 30px rgba(0,0,0,.35);font:500 13.5px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1);border-left:3px solid var(--ovui-c,var(--accent,#3b82f6))}
+.ovui-toast[data-type=success]{--ovui-c:var(--success,#22c55e)}
+.ovui-toast[data-type=error]{--ovui-c:var(--danger,#ef4444)}
+.ovui-toast[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}
+.ovui-toast.is-out{animation:ovuiOut .2s ease forwards}
+.ovui-dot{width:8px;height:8px;border-radius:50%;background:var(--ovui-c,var(--accent,#3b82f6));margin-top:6px}
+.ovui-title{font-weight:700;margin-bottom:1px}
+.ovui-msg{color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere}
+.ovui-title+.ovui-msg{font-weight:400}
+.ovui-toast .ovui-msg:first-child{color:inherit}
+.ovui-x,.ovui-act{appearance:none;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:8px}
+.ovui-x{opacity:.55;padding:2px 6px;line-height:1;font-size:16px}
+.ovui-x:hover{opacity:1}
+.ovui-act{margin-top:6px;padding:4px 10px;font-weight:700;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);text-decoration:none;display:inline-block}
+.ovui-back{position:fixed;inset:0;z-index:2147483100;background:rgba(3,6,12,.6);backdrop-filter:blur(3px);display:grid;place-items:center;padding:16px;animation:ovuiFade .15s ease}
+.ovui-dialog{width:min(420px,100%);background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:16px;padding:20px;box-shadow:0 24px 60px rgba(0,0,0,.5);font:400 14.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .22s cubic-bezier(.2,1.2,.3,1)}
+.ovui-dialog h2{margin:0 0 6px;font-size:17px;font-weight:700}
+.ovui-dialog p{margin:0;color:var(--text-secondary,#a8b3c4);white-space:pre-wrap;overflow-wrap:anywhere}
+.ovui-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}
+.ovui-btn{appearance:none;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:600 14px/1 system-ui,sans-serif;padding:10px 16px;border-radius:10px;cursor:pointer}
+.ovui-btn:hover{background:rgba(127,127,127,.12)}
+.ovui-btn.is-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)}
+.ovui-btn.is-danger{background:var(--danger,#ef4444);border-color:transparent;color:#fff}
+.ovui-btn:focus-visible,.ovui-x:focus-visible,.ovui-act:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
+@keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}
+@keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}}
+@keyframes ovuiFade{from{opacity:0}to{opacity:1}}
+@media (max-width:560px){.ovui-toasts{left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));max-width:none}}
+@media (prefers-reduced-motion:reduce){.ovui-toast,.ovui-dialog,.ovui-back{animation:none!important}}`;
+
+ function css() { if (document.getElementById('ovui-css')) return; const s = document.createElement('style'); s.id = 'ovui-css'; s.textContent = CSS; document.head.appendChild(s); }
+ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; }
+ function safeHref(h) { try { const u = new URL(h, location.href); return /^https?:$/.test(u.protocol) ? u.href : null; } catch { return null; } }
+
+ let stack = null;
+ const MAX = 4;
+
+ function toast(message, opts) {
+ const o = opts || {};
+ css();
+ if (!stack || !stack.isConnected) { stack = el('div', 'ovui-toasts'); stack.setAttribute('aria-live', 'polite'); document.body.appendChild(stack); }
+ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';
+ const t = el('div', 'ovui-toast'); t.dataset.type = type; t.setAttribute('role', type === 'error' ? 'alert' : 'status');
+ t.appendChild(el('span', 'ovui-dot'));
+ const body = el('div');
+ if (o.title) body.appendChild(el('div', 'ovui-title', o.title));
+ body.appendChild(el('div', 'ovui-msg', message));
+ if (o.action && o.action.label) {
+ const href = o.action.href && safeHref(o.action.href);
+ const a = el(href ? 'a' : 'button', 'ovui-act', o.action.label);
+ if (href) a.href = href; else a.type = 'button';
+ a.addEventListener('click', (ev) => { try { o.action.onClick && o.action.onClick(ev); } finally { close(); } });
+ body.appendChild(a);
+ }
+ t.appendChild(body);
+ const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close()); t.appendChild(x);
+ stack.appendChild(t);
+ while (stack.children.length > MAX) stack.firstChild.remove();
+
+ const ttl = o.ttl === 0 ? 0 : (o.ttl || (type === 'error' ? 8000 : 4500));
+ let timer = null, closed = false;
+ const arm = () => { if (ttl) timer = setTimeout(close, ttl); };
+ function close() { if (closed) return; closed = true; clearTimeout(timer); t.classList.add('is-out'); setTimeout(() => t.remove(), 220); }
+ t.addEventListener('mouseenter', () => clearTimeout(timer)); t.addEventListener('mouseleave', arm);
+ arm();
+ return { close, el: t };
+ }
+
+ function dialog(o, withCancel) {
+ css();
+ return new Promise((resolve) => {
+ const prev = document.activeElement;
+ const back = el('div', 'ovui-back');
+ const d = el('div', 'ovui-dialog'); d.setAttribute('role', withCancel ? 'alertdialog' : 'dialog'); d.setAttribute('aria-modal', 'true');
+ const h = el('h2', null, o.title || (withCancel ? 'Are you sure?' : 'Notice')); h.id = 'ovui-h-' + Date.now(); d.setAttribute('aria-labelledby', h.id); d.appendChild(h);
+ if (o.message) d.appendChild(el('p', null, o.message));
+ const btns = el('div', 'ovui-btns');
+ let cancel = null;
+ if (withCancel) { cancel = el('button', 'ovui-btn', o.cancelLabel || 'Cancel'); cancel.type = 'button'; btns.appendChild(cancel); }
+ const ok = el('button', 'ovui-btn ' + (o.danger ? 'is-danger' : 'is-primary'), o.confirmLabel || (withCancel ? 'Confirm' : 'OK')); ok.type = 'button'; btns.appendChild(ok);
+ d.appendChild(btns); back.appendChild(d); document.body.appendChild(back);
+ function done(v) { document.removeEventListener('keydown', onKey, true); back.remove(); try { prev && prev.focus && prev.focus(); } catch { /* */ } resolve(v); }
+ function onKey(e) {
+ if (e.key === 'Escape') { e.preventDefault(); done(false); }
+ else if (e.key === 'Tab') { const f = [cancel, ok].filter(Boolean); const i = f.indexOf(document.activeElement); e.preventDefault(); f[(i + (e.shiftKey ? f.length - 1 : 1)) % f.length].focus(); }
+ }
+ document.addEventListener('keydown', onKey, true);
+ back.addEventListener('mousedown', (e) => { if (e.target === back) done(false); });
+ ok.addEventListener('click', () => done(true));
+ if (cancel) cancel.addEventListener('click', () => done(false));
+ (o.danger && cancel ? cancel : ok).focus();
+ });
+ }
+
+ const api = {
+ toast,
+ success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })),
+ error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })),
+ warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })),
+ confirm: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), true),
+ alert: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), false).then(() => undefined),
+ };
+ root.OpenVibeUI = api;
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+})(typeof window !== 'undefined' ? window : globalThis);