`;
}
return `` + pick.map(n => `${escapeAttr(n.name)}`).join('');
}
/** The menu's Display row: light, dark or auto as icons, and Settings opening the Look panel (text size, motion, theme,
* icons) in place, the same as the account menu's (11-09, the owner: "moved to a settings menu… more compact"). */
function bindDisplayControls(box) {
const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;
const say = () => { const sch = L.scheme ? L.scheme.get() : null; const d = L.display.get(); return [{ light: 'Light', dark: 'Dark', auto: 'Auto' }[sch] || '', d.motion === 'reduced' ? 'Calm' : '', String(d.text) !== '100' ? 'Large text' : ''].filter(Boolean).join(' · '); };
box.innerHTML = `${navIcon('fa-palette')}Display and theme${navIcon('fa-chevron-down')}`;
const paint = () => { const s = box.querySelector('[data-disp-say]'); if (s) s.textContent = say(); };
bindLook(box);
if (typeof root.addEventListener === 'function') { root.addEventListener('ov:theme', paint, onDocument()); root.addEventListener('ov:display', paint, onDocument()); }
paint();
box.hidden = false;
}
// ── Notifications (count and list are mounted in You) ─────
function mountNotifications(nav, u) {
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
const avatar = nav.querySelector('#openvibe-avatar-btn');
const badge = avatar?.parentElement?.querySelector('.ovnav-avatar-count');
if (badge) badge.hidden = true;
const go = () => {
const N = root.OpenVibeNotifications; if (!N) return;
try {
if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network', realtime: _config.notificationsRealtime === true }); N.__ovNavInit = true; }
const paint = () => { if (!badge) return; const n = Number(N.unread()) || 0; badge.textContent = n > 99 ? '99+' : String(n); badge.hidden = !n; };
paint(); document.addEventListener('openvibe-notifications-count', paint);
} 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 = siblingV('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
}
// ── The menu's Chat and Vibe (dock.js's panels; 4.220.0: no floating Dock). For a signed-in person dock.js reads their
// state (a Network without the route is not asked again for a day) and paints its status on the menu button.
const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');
const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, agentBase: _config.dockAgentBase, me: _config.user && _config.user.username, sibling,
status: (s) => { _menuStatus = s || null; }, openMenu: (id) => openMenuOn(id), closeMenu: () => { if (root.OpenVibeSearch && menuOpen()) root.OpenVibeSearch.close(); } });
let _menuStatus = null;
/** The menu on one of its places ('chats', 'vibe', 'code', 'agent'): opened there, or moved there if it is open. */
/** 4.229.0: Back to a page the menu was left from opens the menu again, where it was (its place, its door, its
* scroll), whether the page comes back from the browser's cache, loads again, or is swapped in place. Only for a
* Back or Forward, and only within half an hour of leaving. */
function restoreMenu(ev) {
let st = null;
try { st = history.state; } catch { return; }
const b = st && st.ovsBack;
if (!b || typeof b !== 'object' || !(Date.now() - Number(b.at || 0) < 30 * 60000)) return;
let nav = null;
try { nav = performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; } catch { nav = null; }
if (!(ev && ev.persisted) && !(nav && nav.type === 'back_forward')) return;
if (menuOpen()) return;
const tab = /^(here|feed|you)$/.test(String(b.tab || '')) ? b.tab : 'here';
const door = /^(vibe|chats|agent|code|tasks)$/.test(String(b.door || '')) ? b.door : undefined;
const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
openSearch('', tab, orbs.find((x) => x.getBoundingClientRect && x.getBoundingClientRect().width > 0) || null, null, door, { restore: { scroll: Math.max(0, Number(b.scroll) || 0) } });
}
/** 4.229.0 (the owner, 11-12: "other tools and buttons can open specific areas of the menu"): a menu address is
* place[/part]. Places: here, feed, you, and the doors vibe, chats, agent, code, tasks. Parts: You's notifications,
* recent, apps (All apps), feeds, display; this site's live, places, tools, all. A part alone names its place
* ("notifications" is you/notifications). Anything else opens the menu on this site. */
const MENU_PARTS = { here: /^(live|places|tools|all|discover|news)$/, you: /^(notifications|recent|apps|feeds|display|home)$/, feed: /^$/ };
function menuAddress(addr) {
const [a = '', b = ''] = String(addr || '').trim().toLowerCase().replace(/^#?ov-menu=/, '').split('/');
if (/^(vibe|chats|agent|code|tasks)$/.test(a)) return { door: a };
if (MENU_PARTS.you.test(a)) return { tab: 'you', part: a };
if (MENU_PARTS.here.test(a)) return { tab: 'here', part: a };
const tab = /^(here|feed|you)$/.test(a) ? a : 'here';
return { tab, part: MENU_PARTS[tab].test(b) && b ? b : null };
}
function openMenuAt(addr, fromEl) {
const m = menuAddress(addr);
if (m.door) return openMenuOn(m.door);
openSearch('', m.tab, fromEl && fromEl.getBoundingClientRect ? fromEl : null, m.part);
}
/** An address in the page's own: #ov-menu=you/notifications (alone, or after other hash words with &). It opens
* as the page loads or when a link on the page changes the hash, and leaves the address so Back is the page. */
function menuFromHash() {
let h = '';
try { h = root.location.hash || ''; } catch { return; }
const m = /(?:^#|&)ov-menu=([a-z/-]{1,40})(?=&|$)/.exec(h);
if (!m) return;
const rest = h.replace(m[0], '').replace(/^#&/, '#');
try { history.replaceState(history.state, '', root.location.pathname + root.location.search + (rest === '#' ? '' : rest)); } catch { /* the address keeps it */ }
openMenuAt(m[1]);
}
function openMenuOn(id) {
if (menuOpen() && root.OpenVibeSearch && root.OpenVibeSearch.door && root.OpenVibeSearch.door(id)) return;
const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
openSearch('', 'here', orbs.find((x) => x.getBoundingClientRect && x.getBoundingClientRect().width > 0) || null, null, id);
}
function loadDock(then) {
if (root.OpenVibeDock) return then();
let sc = document.getElementById('ov-dock-loader');
if (!sc) { sc = document.createElement('script'); sc.id = 'ov-dock-loader'; sc.src = siblingV('dock.js'); sc.async = true; document.head.appendChild(sc); }
sc.addEventListener('load', () => setTimeout(() => { try { then(); } catch { /* */ } }, 0));
}
let _dockOn = false;
function mountDock(u) {
const on = _config.dock !== false && !!u && !u.is_anon && !!_config.token;
_dockOn = on;
if (root.OpenVibeDock) return root.OpenVibeDock.mount(on ? dockOpts() : null);
try { const k = JSON.parse(localStorage.getItem('ov-dock') || 'null'); if (!on || (k && k.off > Date.now())) return; } catch { if (!on) return; }
loadDock(() => root.OpenVibeDock.mount(dockOpts()));
}
// Your online status (presence.js, 4.131.0): its dot on your picture, a heartbeat, the picker. presence: false turns it off.
function mountPresence(u) {
if (_config.presence === false || !u || u.is_anon || !_config.token) return;
const go = () => root.OpenVibePresence && root.OpenVibePresence.start({ token: () => _config.token, apiBase: dockBase(), user: u });
if (root.OpenVibePresence) return go();
if (document.getElementById('ov-presence-loader')) return;
const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = siblingV('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
}
// Load portable cards on arrival or on request.
function loadOverlay(then) {
if (root.OVOverlay) { if (then) then(root.OVOverlay); return; }
let sc = document.getElementById('ov-overlay-loader');
if (!sc) { sc = document.createElement('script'); sc.id = 'ov-overlay-loader'; sc.src = siblingV('overlay.js'); sc.async = true; document.head.appendChild(sc); }
if (then) sc.addEventListener('load', () => { if (root.OVOverlay) then(root.OVOverlay); }, { once: true });
}
function watchOverlays() {
let waiting = false;
try { waiting = /(?:^#|&)ovo=/.test(root.location.hash || '') || !!sessionStorage.getItem('ov-overlay'); } catch { /* private mode */ }
if (waiting) loadOverlay();
}
// Faster to the next OpenVibe site: when a pointer or finger reaches for a link to another one, connect to it then.
function warmLinks() {
if (root.__ovWarm || typeof document.addEventListener !== 'function') return;
root.__ovWarm = true;
const done = new Set();
const warm = (e) => {
const a = e.target && e.target.closest && e.target.closest('a[href]');
if (!a) return;
let u; try { u = new URL(a.href, root.location.href); } catch { return; }
if (u.protocol !== 'https:' || u.origin === root.location.origin || done.has(u.origin) || !/(^|\.)openvibe\.[a-z]+$/.test(u.hostname) || done.size > 12) return;
done.add(u.origin);
const l = document.createElement('link'); l.rel = 'preconnect'; l.href = u.origin; l.crossOrigin = ''; document.head.appendChild(l);
const d = document.createElement('link'); d.rel = 'dns-prefetch'; d.href = u.origin; document.head.appendChild(d);
};
document.addEventListener('pointerover', warm, { passive: true, capture: true });
document.addEventListener('touchstart', warm, { passive: true, capture: true });
document.addEventListener('focusin', warm, { passive: true, capture: true });
// A link that leaves this page shows the top bar at once (the owner, 11-09: "a progress indicator at the very
// top… a glow… OpenVibe looking"); Boost's own moves draw the same bar (route-transition.js). Listening on the
// window, after the page's own handlers: a click a page or Boost took (defaultPrevented) draws nothing here.
if (typeof root.addEventListener !== 'function') return;
root.addEventListener('click', (e) => {
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target && e.target.closest && e.target.closest('a[href]');
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return;
let u; try { u = new URL(a.href, root.location.href); } catch { return; }
const here = root.location;
if (!/^https?:$/.test(u.protocol) || (u.origin === here.origin && u.pathname === here.pathname && u.search === here.search)) return;
let b = document.getElementById('ovnav-leave');
if (!b) { b = document.createElement('div'); b.id = 'ovnav-leave'; b.className = 'ovrt-bar'; b.setAttribute('aria-hidden', 'true'); document.body.appendChild(b); }
b.classList.remove('is-done'); void b.offsetWidth; b.classList.add('is-run');
});
// Back from the next page (the browser's page cache): the bar is gone.
root.addEventListener('pageshow', () => { const b = document.getElementById('ovnav-leave'); if (b) b.classList.remove('is-run'); });
}
// The menu on a place ('chats' | 'vibe' | 'code' | 'agent'); opts.task marks an agent's step and brings it into view.
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
// Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first.
function askAgent(text) {
if (!_config.token || _config.dock === false) { const u = resolveLoginHref(currentHost(), location.href); if (u) location.href = u; return; }
loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); });
}
if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('click', (e) => { const b = e.target && e.target.closest && e.target.closest('[data-ov-ask]'); if (!b) return; e.preventDefault(); askAgent(b.getAttribute('data-ov-ask') || ''); });
/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
function sectionsHTML(sections) {
return (Array.isArray(sections) ? sections : []).filter(sec => sec && Array.isArray(sec.items) && sec.items.length).map(sec =>
`
${sec.label ? `
${escapeAttr(sec.label)}
` : ''}${sec.items.map(menuItemHTML).join('')}
`).join('');
}
const sectionItems = (sections) => (Array.isArray(sections) ? sections : []).reduce((o, sec) => o.concat((sec && sec.items) || []), []);
// What this person opens from the menu, counted on this device (no request, nothing leaves the browser): the grid
// puts their most used first once a place has been opened twice; until then the site's own order stands.
const USE_KEY = 'ov-menu-use';
const useKey = (item) => String(item.id || item.href || item.label || '').slice(0, 80);
function useCounts() { try { return JSON.parse(localStorage.getItem(USE_KEY) || '{}') || {}; } catch { return {}; } }
function countUse(key) {
if (!key) return;
try { const c = useCounts(); c[key] = Math.min(9999, (Number(c[key]) || 0) + 1); const keys = Object.keys(c); if (keys.length > 60) keys.sort((x, y) => c[x] - c[y]).slice(0, keys.length - 60).forEach((k) => delete c[k]); localStorage.setItem(USE_KEY, JSON.stringify(c)); } catch { /* private mode */ }
}
function byUse(items) {
const c = useCounts(), n = (it) => { const v = Number(c[useKey(it)]) || 0; return v >= 2 ? v : 0; };
return items.map((it, i) => [it, i]).sort((x, y) => (x[0].hidden ? 1 : 0) - (y[0].hidden ? 1 : 0) || n(y[0]) - n(x[0]) || x[1] - y[1]).map(([it]) => it);
}
/** One place in the account menu's grid: an icon over a short name (item.short, else its label). */
function tileHTML(item) {
const attrs = ` class="ud-tile${item.danger ? ' danger' : ''}" data-use="${escapeAttr(useKey(item))}" title="${escapeAttr(item.label)}"${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}${item.mark ? ` ${item.mark}` : ''}`;
const inner = `${item.drawn || navIcon(item.icon || 'fa-circle-info')}${escapeAttr(item.short || item.label)}${item.value !== undefined ? `${escapeAttr(item.value)}` : ''}`;
return item.href ? `${inner}` : ``;
}
function menuItemHTML(item) {
if (!item) return '';
if (item.sep) return '';
const icon = item.drawn ? `${item.drawn}` : item.icon ? `${navIcon(item.icon)}` : '';
const cls = item.danger ? ' class="danger"' : '';
const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''} data-use="${escapeAttr(useKey(item))}"${item.mark ? ` ${item.mark}` : ''}`;
const val = item.value !== undefined ? `${escapeAttr(item.value)}` : '';
if (item.href) return `${icon} ${escapeAttr(item.label)}${val}`;
return ``;
}
function bindMenuItems(container, items) {
for (const item of items) {
if (!item || !item.id || typeof item.onClick !== 'function') continue;
container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); const r = item.onClick(e); if (r === false) e.preventDefault(); if (item.keepOpen !== true) container.closest('.openvibe-navbar-dropdown')?.classList.remove('open'); });
}
}
/** One top-level link. Supports { label, href, icon, id, elId, page, hidden, external, dot, dotId,
featured, children: [...] }. featured: true draws it as the site's one accent pill (4.117.0). */
function linkHTML(l, inDrawer) {
const attrs = `${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.elId ? ` id="${escapeAttr((inDrawer ? 'drawer-' : '') + l.elId)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}${l.hidden ? ' hidden' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}`;
const kids = Array.isArray(l.children) ? l.children.filter(Boolean) : [];
const inner = `${l.icon ? `${navIcon(l.icon)}` : ''}${escapeAttr(l.label)}${l.dot !== undefined ? `` : ''}`;
const a = `${inner}${kids.length && !inDrawer ? '▾' : ''}`;
if (!kids.length) return a;
const menu = kids.map(k => `${k.icon ? `${navIcon(k.icon)}` : ''}${escapeAttr(k.label)}`).join('');
return inDrawer ? a + menu : `
${a}
${menu}
`;
}
/** 4.119.0: a featured link with an icon keeps a button on the phone's bar. */
function featuredPhoneHTML(links) {
const f = (links || []).find(l => l && l.featured && !l.hidden && l.icon);
if (!f) return '';
return `${navIcon(f.icon)}`;
}
const flatLinks = (list) => list.reduce((out, l) => out.concat([l], Array.isArray(l.children) ? l.children : []), []);
/** The drawer's order: a featured link leads the menu (the phone's first entry), the rest keep theirs. */
function drawerOrder(list) {
const items = (list || []).filter(Boolean);
return items.filter(l => l.featured && !l.hidden).concat(items.filter(l => !(l.featured && !l.hidden)));
}
function currentLinks() {
const list = _runtimeLinks || _config.links || linksFor(_config.service);
const path = (typeof location !== 'undefined' && location.pathname) || '/';
const activePage = _activePage;
return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (activePage ? l.page === activePage : (l.href === path && path !== '/')) }));
}
let _activePage = null; // setActive(): single-page apps tell the navbar where they are
const SERVICE_LINKS = {
live: [
{ label: 'Watch', href: '/' },
{ label: 'Chat', href: '/chat' },
{ label: 'VODs', href: '/vods' },
{ label: 'Game', href: '/game' },
],
tools: [
{ label: 'Home', href: '/' },
],
games: [
{ label: 'Play', href: '/game' },
{ label: 'Canvas', href: '/canvas' },
{ label: 'Leaderboard', href: '/leaderboard' },
],
media: [
{ label: 'Home', href: '/' },
{ label: 'Network', href: 'https://openvibe.network' },
{ label: 'Live', href: 'https://openvibe.live' },
{ label: 'Tools', href: 'https://openvibe.tools' },
],
network: [
{ label: 'Home', href: '/' },
{ label: 'My Account', href: '/my' },
{ label: 'Themes', href: '/themes' },
],
maps: [
{ label: 'Map', href: '/' },
{ label: 'Camps', href: '/camps' },
],
food: [
{ label: 'Food Banks', href: '/' },
{ label: 'Meal Plan', href: '/#meal-plan' },
],
img: [
{ label: 'Convert', href: 'https://convert.openvibe.tools' },
{ label: 'Compress', href: 'https://compress.openvibe.tools' },
{ label: 'Resize', href: 'https://resize.openvibe.tools' },
{ label: 'Crop', href: 'https://crop.openvibe.tools' },
],
yt: [
{ label: 'Download', href: '/' },
],
audio: [
{ label: 'Convert', href: 'https://audio.openvibe.tools' },
{ label: 'Trim', href: 'https://trim.openvibe.tools' },
{ label: 'Pitch', href: 'https://pitch.openvibe.tools' },
{ label: 'Reverb', href: 'https://reverb.openvibe.tools' },
],
text: [
{ label: 'Fancy', href: 'https://fancy.openvibe.tools' },
{ label: 'Zalgo', href: 'https://zalgo.openvibe.tools' },
{ label: 'ASCII', href: 'https://ascii.openvibe.tools' },
{ label: 'Symbols', href: 'https://symbols.openvibe.tools' },
],
logo: [
{ label: 'Title', href: 'https://title.openvibe.tools' },
{ label: 'Wordmark', href: 'https://wordmark.openvibe.tools' },
{ label: 'Badge', href: 'https://badge.openvibe.tools' },
{ label: 'Thumbnail', href: 'https://thumbnail.openvibe.tools' },
],
docs: [
{ label: 'Merge', href: 'https://mergepdf.openvibe.tools' },
{ label: 'Split', href: 'https://splitpdf.openvibe.tools' },
{ label: 'Compress', href: 'https://compresspdf.openvibe.tools' },
{ label: 'Images to PDF', href: 'https://image2pdf.openvibe.tools' },
],
net: [
{ label: 'Lookup', href: 'https://lookup.openvibe.tools' },
{ label: 'My IP', href: 'https://myip.openvibe.tools' },
{ label: 'DNS', href: 'https://dns.openvibe.tools' },
{ label: 'Ping', href: 'https://ping.openvibe.tools' },
{ label: 'SSL', href: 'https://ssl.openvibe.tools' },
],
dev: [
{ label: 'JSON', href: 'https://json.openvibe.tools' },
{ label: 'Base64', href: 'https://base64.openvibe.tools' },
{ label: 'JWT', href: 'https://jwt.openvibe.tools' },
{ label: 'Regex', href: 'https://regex.openvibe.tools' },
{ label: 'Diff', href: 'https://diff.openvibe.tools' },
],
};
// The server shell calls this same table so its HTML matches the browser defaults.
function linksFor(service) {
return (SERVICE_LINKS[service] || []).map(link => ({ ...link }));
}
// Accounts seen here (no tokens).
const SEEN_KEY = 'openvibe_seen_accounts';
function readList(key) { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }
function getAccounts() {
const ids = new Set();
return readList('openvibe_accounts').concat(readList(SEEN_KEY)).filter((a) => {
if (!a || a.id == null || ids.has(String(a.id))) return false;
ids.add(String(a.id));
return true;
});
}
function rememberAccount(u) {
if (!u || u.is_anon || u.id == null || !u.username) return;
try {
const seen = readList(SEEN_KEY).filter((a) => a && String(a.id) !== String(u.id));
seen.unshift({ id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null });
localStorage.setItem(SEEN_KEY, JSON.stringify(seen.slice(0, 10)));
} catch { /* storage unavailable */ }
}
function signInBack() {
if (onNetworkDomain()) return window.location.href;
try { return new URL(resolveLoginHref(currentHost(), window.location.href), window.location.href).toString(); }
catch { return window.location.href; }
}
function switchHref(account) {
const q = new URLSearchParams({ switch_to: String(account.id), return: signInBack() });
if (account.username) q.set('login_hint', String(account.username));
return `${_config.apiBase}/login?${q.toString()}`;
}
function addAccountHref() {
return `${_config.apiBase}/login?${new URLSearchParams({ add_account: '1', return: signInBack() }).toString()}`;
}
// ─── SSO auth resolution ───────────────────────────────────
// ov_token is host-only on openvibe.network and Domain=.openvibe.tools on the tools (CONTRACTS.md). The tools'
// apex owns the OAuth client (/auth/*); its subdomains only read the cookie, so sign-in/out goes through the apex.
// Order: the cookie, localStorage, the page's token, then config.sessionUrl.
const TOOLS_APEX = 'openvibe.tools';
const TOKEN_KEY = 'ov_token';
function currentHost() {
return (typeof location !== 'undefined' && location.hostname) || '';
}
function onToolsDomain(host) {
const h = host !== undefined ? host : currentHost();
return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX);
}
function onNetworkDomain(host) {
const h = host !== undefined ? host : currentHost();
try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; }
}
function parseCookieToken(cookieStr) {
const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/);
if (!m || !m[1]) return null;
try { return decodeURIComponent(m[1]); } catch { return m[1]; }
}
function getCookieToken() {
if (typeof document === 'undefined') return null;
return parseCookieToken(document.cookie);
}
function getStoredToken() {
try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; }
}
function resolveToken() {
return getCookieToken() || getStoredToken() || _config.token || null;
}
/** Where "Sign In" goes on this host. */
function resolveLoginHref(host, returnUrl) {
if (_config.loginUrl) return fillUrl(_config.loginUrl, returnUrl);
if (onToolsDomain(host)) {
// *.openvibe.tools signs in through the gateway apex (its OAuth state; ov_token on Domain=.openvibe.tools).
return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;
}
if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`;
}
function setAuthCookie(token) {
if (typeof document === 'undefined') return;
const maxAge = 24 * 60 * 60; // matches the 24h access JWT
if (onToolsDomain()) {
document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`;
} else {
const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
}
}
function clearAuthState() {
if (typeof document !== 'undefined') {
document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
if (onToolsDomain()) {
document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`;
}
}
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('openvibe_anon_token');
localStorage.removeItem('openvibe_active_account');
} catch { /* storage unavailable */ }
}
function persistToken(token) {
try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ }
setAuthCookie(token);
}
/** GET {apiBase}/api/auth/me with the Bearer token. */
async function fetchMe(token) {
try {
const res = await fetch(`${_config.apiBase}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
return { ok: true, user: (data && (data.user || data)) || null };
}
return { ok: false, unauthorized: res.status === 401 || res.status === 403 };
} catch {
return { ok: false, unauthorized: false };
}
}
/** loginUrl/logoutUrl templates: {url} = the full return URL, {path} = its path + query (for sites that only accept local next=). */
function fillUrl(template, returnUrl) {
const full = returnUrl || (typeof location !== 'undefined' ? location.href : '/');
let path = '/';
try { const u = new URL(full, typeof location !== 'undefined' ? location.href : 'https://openvibe.network/'); path = u.pathname + u.search; } catch { /* */ }
return String(template).replace('{url}', encodeURIComponent(full)).replace('{path}', encodeURIComponent(path));
}
/** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */
async function gatewayRefresh() {
try {
const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json();
return data && data.token ? data : null;
} catch { return null; }
}
/** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */
async function networkRefresh(token) {
try {
const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const data = await res.json();
return data && data.token ? data : null;
} catch { return null; }
}
/**
* Resolve the signed-in user from the shared SSO state.
* With a token: validate it against the Network. If it is rejected, make
* ONE refresh attempt (same-origin gateway refresh first on tools hosts,
* then the Network's grace refresh) before giving up — never leave a
* stale "Sign In" when a recoverable session exists.
*/
async function resolveSessionUser() {
const token = resolveToken();
if (token) {
const me = await fetchMe(token);
if (me.ok && me.user) return { user: me.user, token };
if (me.unauthorized) {
if (onToolsDomain()) {
const r = await gatewayRefresh();
if (r) {
const user = r.user || (await fetchMe(r.token)).user;
if (user) { persistToken(r.token); return { user, token: r.token }; }
}
}
const n = await networkRefresh(token);
if (n) {
const user = n.user || (await fetchMe(n.token)).user;
if (user) { persistToken(n.token); return { user, token: n.token }; }
}
// A stored token nobody accepts any more: drop it, so it cannot keep hiding a
// good session (the site's own below, or the next sign-in).
try { if (localStorage.getItem('ov_token') === token) localStorage.removeItem('ov_token'); } catch { /* */ }
}
if (!_config.sessionUrl) return null;
}
// No usable token — ask the page's own session endpoint if it has one (a site with a
// server-side session, such as openvibe.blog, knows who is signed in even when this
// browser's stored token is stale or was never shared with the page).
if (_config.sessionUrl) {
try {
const res = await fetch(_config.sessionUrl, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
const user = (data && (data.user || data)) || null;
if (user && (user.username || user.id)) {
let token = data && data.token || null;
// Sites with HTTP-only access cookies expose a verified access token through a
// same-origin, no-store endpoint for the Dock and the notification bell.
if (!token && _config.tokenUrl) {
try {
const tokenRes = await fetch(_config.tokenUrl, { credentials: 'same-origin', cache: 'no-store' });
if (tokenRes.ok) token = (await tokenRes.json()).token || null;
} catch { /* the account menu still works without the Dock */ }
}
return { user, token };
}
}
} catch { /* signed out */ }
}
return null;
}
let _authInFlight = null;
let _hintPainted = false, _hintSettled = false;
function ssoHint() {
const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
if (m) return m[1];
try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }
}
/**
* The first paint, before the session check: a hint that this browser signed in here. It is not
* authorization and carries no token; only the account's own name may ride along (account:).
*/
function hintUser() {
let raw = ssoHint() || '';
try { raw = decodeURIComponent(raw); } catch { /* as written */ }
const m = /^account(?::(.{1,64}))?$/.exec(raw);
if (!m) return null;
let name = (m[1] || '').replace(/^@/, '');
return { is_anon: false, username: /^[A-Za-z0-9_.-]{1,64}$/.test(name) ? name : '', __ssoHint: true };
}
/** One silent sign-in per tab, only after an earlier sign-in here (never after 'guest', never for bots): silent=1. */
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 = siblingV('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;
}
/**
* Use a top-level prompt=none round trip so Network's session is first-party even when
* third-party cookies are blocked. An iframe's negative answer cannot distinguish a guest
* from a signed-in person in those browsers, and first-use FedCM needs interaction.
* At most once per tab per 10 minutes; never after a sign-out ('guest'); never for bots.
* The SDK returns guests with sso=none instead of showing an interactive login page.
*/
function maybeSilentLogin() {
if (!_config.silentLogin || typeof location === 'undefined') return false;
if (ssoHint() === 'guest') return false;
if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
if (/[?&]sso=none\b/.test(location.search)) return false;
try {
const last = +sessionStorage.getItem('ov_silent_sso_at') || 0;
if (Date.now() - last < 10 * 60 * 1000) return false;
sessionStorage.setItem('ov_silent_sso_at', String(Date.now()));
} catch { return false; }
// The top-level round trip sees Network's session even where third-party cookies are blocked (phones), but it
// costs a redirect. Take it when there is a reason to think this person has one: this site signed them in
// before ('account'), or they came here from another OpenVibe site (the owner, 11-08: signed in to the network,
// opened openvibe.link from the menu, saw Sign In). A first visit from a search engine keeps the quiet iframe
// check and FedCM instead, and Sign in is one tap.
if (ssoHint() === 'account' || fromOpenVibe()) return silentLoginNow();
checkNetworkSession().then((state) => {
if (state && state.signedIn) return silentLoginNow();
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;
}
/** This page was opened from another OpenVibe site (its referrer's host is first-party, and not this one). */
function fromOpenVibe() {
try {
const r = new URL(document.referrer);
return r.protocol === 'https:' && r.hostname !== location.hostname && /(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/.test(r.hostname);
} catch { return false; }
}
/** Ask the network (hidden iframe + postMessage) whether this browser is signed in there. */
function checkNetworkSession(timeoutMs = 4000) {
return new Promise((resolve) => {
let done = false, frame = null, timer = null;
const finish = (v) => { if (done) return; done = true; clearTimeout(timer); window.removeEventListener('message', onMsg); try { frame?.remove(); } catch { /* */ } resolve(v); };
const onMsg = (e) => {
if (e.origin !== _config.apiBase || !e.data || e.data.type !== 'ov-sso') return;
finish({ signedIn: !!e.data.signedIn, username: e.data.username || null });
};
try {
window.addEventListener('message', onMsg);
frame = document.createElement('iframe');
frame.setAttribute('aria-hidden', 'true'); frame.setAttribute('tabindex', '-1');
frame.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';
frame.src = `${_config.apiBase}/sso/check?origin=${encodeURIComponent(location.origin)}`;
(document.body || document.documentElement).appendChild(frame);
timer = setTimeout(() => finish(null), timeoutMs);
} catch { finish(null); }
});
}
function recordHistory() {
const h = _config.history;
if (!h || !_config.user || _config.user.is_anon) return;
const rec = Object.assign({ url: location.href, title: document.title, service: resolveBrand().tld }, h);
const H = root.OpenVibeHistory;
if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
if (!document.getElementById('ov-history-loader')) {
const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
sc.src = siblingV('history.js');
sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
document.head.appendChild(sc);
} else {
document.getElementById('ov-history-loader').addEventListener('load', () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } });
}
}
let _questGuideStarted = false;
function startQuestGuide() {
if (_questGuideStarted || !_config.token || !root.location || !/[?&]qstep=/.test(root.location.search || '')) return;
_questGuideStarted = true;
const script = document.createElement('script');
script.src = siblingV('guide.js');
script.onload = () => {
if (!root.OpenVibeQuestGuide) return;
void root.OpenVibeQuestGuide.startFromLocation({ load: async (id) => {
try {
const response = await fetch(`https://openvibe.quest/api/v1/quests/today/steps/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'omit',
});
return response.ok ? response.json() : null;
} catch { return null; }
} });
};
document.head.appendChild(script);
}
function refreshAuthState() {
if (_authInFlight) return _authInFlight;
_authInFlight = resolveSessionUser()
.then((session) => {
_authInFlight = null;
if (session && session.user) {
_config.user = session.user;
if (session.token) _config.token = session.token;
render();
recordHistory();
enableHandoff();
startQuestGuide();
} else {
const navigating = maybeSilentLogin();
// The hint was only a first paint: no session behind it, so draw the guest bar.
if (_hintPainted && !navigating) { _hintPainted = false; _hintSettled = true; render(); }
}
try {
document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
detail: { user: session ? session.user : null, token: session ? session.token : null },
}));
} catch { /* non-DOM environment */ }
return session ? session.user : null;
})
.catch(() => { _authInFlight = null; return null; });
return _authInFlight;
}
function escapeAttr(value) {
return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');
}
function escapeHtml(value) {
return String(value ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function getAvatarInitial(user) {
const source = user?.display_name || user?.username || 'O';
return String(source).trim().charAt(0).toUpperCase() || 'O';
}
function makeAvatarPlaceholder(user, size = 64) {
const initial = getAvatarInitial(user);
const bg = user?.profile_color || '#3b82f6';
const svg = `
`;
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;
}
// The account's picture now (the Network's /avatar/): a record can predate its picture (CHANGELOG 4.12.0).
function currentAvatarUrl(user, size) {
if (!user || user.is_anon || user.id === 'anon' || !user.username) return null;
const network = /(^|\.)openvibe\.network$/.test(currentHost()) ? '' : 'https://openvibe.network';
return `${network}/avatar/${encodeURIComponent(user.username)}?s=${size}`;
}
function avatarSrc(user, size = 64) {
const url = user?.avatar_url;
if (/^https:\/\/openvibe\.media\/o\/med_\w+$/.test(url || '')) return `${url}?w=${size}&fmt=webp`;
if (/^https:\/\/openvibe\.media\//.test(url || '')) return url;
return currentAvatarUrl(user, size) || makeAvatarPlaceholder(user, size);
}
function avatarImg(user, size = 64, className = 'openvibe-navbar-avatar', id = '') {
const fallback = makeAvatarPlaceholder(user, size);
const src = avatarSrc(user, size);
const current = currentAvatarUrl(user, size);
const retry = current && current !== src ? ` data-retry-src="${escapeAttr(current)}"` : '';
const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');
return ``;
}
// A picture that does not load: the current one once (data-retry-src), then the initial.
function attachAvatarFallbacks(rootEl) {
rootEl?.querySelectorAll('img[data-fallback-src]').forEach((img) => {
const onError = () => {
const retry = img.dataset.retrySrc;
if (retry) { delete img.dataset.retrySrc; img.src = retry; return; }
img.removeEventListener('error', onError);
const fallback = img.dataset.fallbackSrc;
if (fallback && img.src !== fallback) img.src = fallback;
};
img.addEventListener('error', onError);
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) onError();
});
}
// Wallet balances fold into one pill and refresh when the tab returns.
const WALLET_CHIPS = [
{ id: 'coins', icon: 'fa-coins', tone: 'gold', label: 'OpenCoins', title: 'Open Wallet' },
{ id: 'vibes', icon: 'fa-wallet', tone: 'green', label: 'Vibes', title: 'Open Wallet' },
{ id: 'balance', icon: 'fa-wallet', label: 'Balance', title: 'Open Wallet' },
];
let _walletFor = null, _walletAt = 0, _walletWatch = false, _walletData = null, _walletScript = null;
const walletKey = (u) => `${u.subject || u.id || u.username || ''}:${_config.token || ''}`;
function walletAmount(b) {
const n = Number(b.amount) / Math.pow(10, b.scale || 0);
return b.currency === 'USD' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(n) : n.toLocaleString();
}
function walletOrder(data) {
const order = ['coins', 'vibes', 'balance'];
if (!data) return order.slice(0, 1);
const value = (id) => Number(data.balances[id].amount) / Math.pow(10, data.balances[id].scale) / (id === 'vibes' ? data.bits_per_usd || 100 : 1);
const have = order.filter((id) => value(id) > 0).sort((a, b) => value(b) - value(a));
if (data.used_ai_this_month && BigInt(data.balances.balance.amount) === 0n) have.unshift('balance');
return have.length ? have : order.slice(0, 1);
}
function syncWalletChips() {
if (!_config.wallet) return;
const u = _config.user;
let chips = (_config.chips || []).filter((c) => !(c && (c.__wallet || ['coins', 'vibes', 'balance'].includes(c.id))));
// false: unreadable, nothing kept: no chips, never a "…" that stays.
const data = u && !u.is_anon && _walletFor === walletKey(u) ? _walletData : null;
if (u && !u.is_anon && data !== false) {
chips = walletOrder(data).map((id) => Object.assign({}, WALLET_CHIPS.find((w) => w.id === id), {
__wallet: true, value: data ? walletAmount(data.balances[id]) : '…', hidden: false, onClick: openWallet,
})).concat(chips);
}
_config.chips = chips;
}
async function openWallet() {
if (!_config.user || _config.user.is_anon) return;
if (!root.OpenVibeWallet) {
if (!_walletScript) _walletScript = new Promise((resolve, reject) => {
const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = siblingV('ov-wallet.css'); document.head.appendChild(css);
const js = document.createElement('script'); js.src = siblingV('ov-wallet.js'); js.onload = resolve;
js.onerror = () => { _walletScript = null; css.remove(); js.remove(); reject(new Error('Wallet could not load')); }; document.head.appendChild(js);
});
try { await _walletScript; } catch { if (_walletScript === null) root.location.assign('https://openvibe.network/wallet'); return; }
}
return root.OpenVibeWallet.open({ getToken: () => _config.token || null, cookieSession: !_config.token, frameBase: _config.frameBase || 'https://openvibe.network/api/frame',
onWallet: (data) => { if (!_config.user) return; _walletFor = walletKey(_config.user); _walletData = data; _walletAt = Date.now(); if (_navEl) render(); } });
}
function loadWalletChips() {
const u = _config.user;
if (!_config.wallet || !u || u.is_anon || typeof fetch !== 'function') return;
const key = walletKey(u), token = _config.token || null;
if (_walletFor === key && Date.now() - _walletAt < 60000) return;
if (_walletFor !== key) _walletData = null;
_walletFor = key; _walletAt = Date.now();
if (!_walletWatch && typeof document !== 'undefined' && document.addEventListener) {
_walletWatch = true;
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { loadWalletChips(); loadUnread(); } });
}
const same = () => _config.user === u && (_config.token || null) === token;
const failed = () => { if (!same() || _walletData) return; _walletData = false; if (_navEl) render(); };
fetch(`${(_config.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet/api/v1/me/wallet`,
token ? { headers: { Authorization: `Bearer ${token}` }, credentials: 'omit' } : { credentials: 'include' })
.then(async (r) => { if (!r.ok) return failed(); const data = await r.json(); if (!same()) return;
_walletData = data; if (_navEl) render();
(root.OpenVibePurse?.catchUp || (v => root.__ovPursePending = v))({ frameBase: _config.frameBase, token, user: u.subject || u.id, streamer: _config.purse?.streamer, valid: same });
}).catch(failed);
}
function render() {
syncWalletChips();
// A site may put state classes on the bar (Live's transparent hero mode). A re-render must not lose them.
let carried = []; try { carried = Array.from((_navEl && _navEl.classList) || []).filter(c => c !== 'openvibe-navbar'); } catch { carried = []; }
if (_navEl) _navEl.remove();
if (_renderAbort) { _renderAbort.abort(); _panelQueue.length = 0; }
_renderAbort = typeof AbortController === 'function' ? new AbortController() : null;
const nav = document.createElement('nav');
nav.className = 'openvibe-navbar';
carried.forEach(c => { try { nav.classList.add(c); } catch { /* */ } });
const svc = _config.service;
const brand = resolveBrand();
if (typeof nav.setAttribute === 'function') { nav.setAttribute('data-compact', _config.compact || 'auto'); nav.setAttribute('data-service', svc); }
const links = currentLinks();
// With the hint saying account, the bar starts in the signed-in shape (a neutral avatar, the
// account's name only when the hint carries it). refreshAuthState replaces or clears it.
const hinted = !_config.user && !_hintSettled && _config.auth !== 'external' ? hintUser() : null;
if (hinted) _hintPainted = true;
const u = _config.user || hinted;
rememberAccount(u);
const accounts = getAccounts();
const isAnon = u && u.is_anon;
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const switcher = !!root.OpenVibeAccountSwitcher; // in place on openvibe.network
// The OV brand mark is a drop-in (mounts every .ov-mark); never a second copy of a page's own tag.
if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {
try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = siblingV('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
}
// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers(); if (_config.forms !== false) watchForms();
if (!root.__ovShareClick) { root.__ovShareClick = true; document.addEventListener('click', shareClick); }
watchOverlays(); warmLinks();
nav.innerHTML = `
${brandHTML(brand)}
`;
dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });
// 4.188.0: not a popup under the picture. It rests in the bar, out of sight (so a site that looks for its
// sign-out button or its own item still finds it), and the menu's You tab hosts it while the menu is open
// (openSearch's account(); onClose brings it back).
dropdown.classList.add('ovnav-account');
nav.appendChild(dropdown);
_accountEl = dropdown;
bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));
bindChips(dropdown);
if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; const path = sitePath(a); if (path != null) { if (_config.onNavigate(path, e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
bindDisplayRows(dropdown);
bindLook(dropdown);
// The picture opens the menu on You, where the account's things are (the selection for Ask Vibe is read on
// pointerdown: the press can clear it).
const avatarBtn = nav.querySelector('#openvibe-avatar-btn'); let sel = null;
avatarBtn.addEventListener('pointerdown', () => { sel = selectedText(); });
avatarBtn.addEventListener('click', () => {
const ask = dropdown.querySelector('[data-ov-ask]'); if (ask) ask.href = askVibeHref(sel ?? selectedText()); sel = null;
if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }
const active = document.querySelector('.ovs.is-open');
if (active && active.querySelector('.ovs-starttabs [data-tab="you"][aria-selected="true"]')) root.OpenVibeSearch.close();
else openSearch('', 'you', avatarBtn, 'notifications');
});
dropdown.querySelectorAll('[data-account-id]').forEach(el => {
el.addEventListener('click', () => {
const id = el.dataset.accountId;
document.dispatchEvent(new CustomEvent('openvibe-switch-account', { detail: { accountId: id } }));
dropdown.classList.remove('open');
});
});
dropdown.querySelector('#openvibe-logout-btn')?.addEventListener('click', () => {
dropdown.classList.remove('open');
if (_config.onLogout) _config.onLogout();
else {
clearAuthState();
_config.user = null;
_config.token = null;
try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
if (_config.logoutUrl) {
// The site ends its own server-side session too, then sends us back.
window.location.href = fillUrl(_config.logoutUrl, window.location.href);
} else if (onToolsDomain()) {
// The gateway also clears the Domain=.openvibe.tools cookie
// and the httpOnly refresh cookie, then sends us back here.
window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;
} else {
window.location.reload();
}
}
});
} else {
nav.querySelector('#openvibe-login-btn')?.addEventListener('click', (event) => {
if (!_config.onLogin) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
_config.onLogin();
});
}
// Insert into page — use navbar-mount placeholder if available, otherwise prepend to body
const mount = document.getElementById('navbar-mount');
if (mount) {
// Replace the crawlable server nav atomically; never leave duplicate landmarks.
if (mount.replaceChildren) mount.replaceChildren(nav);
else { while (mount.firstChild) mount.removeChild(mount.firstChild); mount.appendChild(nav); }
} else {
document.body.prepend(nav);
}
_navEl = nav;
paintSlots();
watchKeyboard();
loadBottom();
attachAvatarFallbacks(nav);
hookTop();
loadWalletChips();
loadUnread();
renderAppBar();
return nav;
}
// bottom.js: the visible viewport for the bar, the small player, the Dock's pill and the composer.
let _bottomLoading = false;
function loadBottom() {
if (_bottomLoading || root.OpenVibeBottom) return;
_bottomLoading = true;
try { const sc = document.createElement('script'); sc.id = 'ovb-loader'; sc.src = siblingV('bottom.js'); sc.async = true; document.head.appendChild(sc); } catch { _bottomLoading = false; }
}
// ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──
let _appBarLoading = false;
const rawLinks = () => _runtimeLinks || _config.links || linksFor(_config.service);
// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
let _searchLoading = false;
// Warm the menu (4.137.0): search.js by its hashed URL is cached for good, so fetching it early costs one download per
// release. At idle after the page settles (not on Save-Data or 2G), and at once when a finger or pointer reaches the
// burger or the orb; the first open then has nothing to wait for.
let _menuWarm = false;
function warmMenu() {
if (_menuWarm || root.OpenVibeSearch || typeof document === 'undefined') return;
_menuWarm = true;
try { const l = document.createElement('link'); l.rel = 'prefetch'; l.as = 'script'; l.href = siblingV('search.js'); document.head.appendChild(l); } catch { /* */ }
}
function warmMenuLater() {
try { const c = root.navigator && root.navigator.connection; if (c && (c.saveData || /(^|-)2g$/.test(c.effectiveType || ''))) return; } catch { /* */ }
const go = () => warmMenu();
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 8000 }); else setTimeout(go, 6000);
}
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
function openSearch(q, tab, fromEl, section, door, extra) {
// The orb that waves while the menu loads: the one pressed (the phone's menu button is an orb too, 4.148.0).
const orb = (fromEl && fromEl.classList && fromEl.classList.contains('ovnav-orb') ? fromEl : null) || (_navEl && _navEl.querySelector('#openvibe-search-btn'));
// The menu drops from the button that opened it (the orb, or the burger), under the bar.
const from = fromEl || orb;
const anchor = () => {
if (!_navEl || !from || !from.getBoundingClientRect) return null;
const bar = _navEl.getBoundingClientRect(), at = from.getBoundingClientRect();
return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6, cx: at.left + at.width / 2, cy: at.top + at.height / 2 } : null;
};
const go = () => {
if (orb) orb.classList.remove('is-busy');
if (!root.OpenVibeSearch) return;
if (_navEl) _navEl.classList.remove('ovnav-hide');
root.OpenVibeSearch.open({ ...(extra || {}), service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,
frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, runsBase: dockBase(), user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
look: bindLook,
dock: _config.dock !== false && _config.user && !_config.user.is_anon && _config.token ? (t) => openDock(t) : null,
dockMore: (action) => { if (root.OpenVibeDock && root.OpenVibeDock.more) root.OpenVibeDock.more(action); },
ask: (text) => { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); }); },
me: _accountEl ? { add: _config.accounts === false ? '' : addAccountHref(), out: () => { const b = _accountEl.querySelector('#openvibe-logout-btn'); if (b) b.click(); }, own: _own } : null,
onClose: () => { if (_accountEl && _navEl && _accountEl.parentNode !== _navEl) _navEl.appendChild(_accountEl); const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });
const b = fromEl || (_navEl && _navEl.querySelector('#openvibe-search-btn')); if (b) b.setAttribute('aria-expanded', 'true');
};
if (root.OpenVibeSearch) return go();
if (_searchLoading) return;
_searchLoading = true;
if (orb) orb.classList.add('is-busy');
try { const sc = document.createElement('script'); sc.src = siblingV('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; if (orb) orb.classList.remove('is-busy'); }; document.head.appendChild(sc); } catch { _searchLoading = false; }
}
const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open'));
// Every site: the menu on its first tab, scrolled to the sites, out of the menu button on screen.
function openSwitcher() {
if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }
if (_menuStatus && _menuStatus.tone === 'waiting') { openMenuOn('agent'); return; }
const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
openSearch('', 'sites', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null);
}
// A signed-in person's visit moves this site to the front of their Recent, at most once in 30 minutes, and never
// under Global Privacy Control or Do Not Track (as the page-view beacon).
let _visitSent = false;
function recordVisit() {
if (_visitSent || !_config.token || !_config.service || typeof fetch === 'undefined' || typeof navigator === 'undefined') return;
if (navigator.globalPrivacyControl || navigator.doNotTrack === '1') return;
_visitSent = true;
const key = 'ov_sw_visit_' + _config.service;
try { if (Date.now() - Number(localStorage.getItem(key) || 0) < 30 * 60000) return; localStorage.setItem(key, String(Date.now())); } catch { /* storage off: still record */ }
fetch('https://openvibe.network/api/themes/me/sites/visit', { method: 'POST', headers: { Authorization: `Bearer ${_config.token}`, 'Content-Type': 'application/json' }, credentials: 'include', keepalive: true, body: JSON.stringify({ site: _config.service }) }).catch(() => {});
}
// The site's name opens its pages (ov-drop.js); with a modifier key it is a link.
function hookPages(nav) {
const a = nav.querySelector('#openvibe-brand-btn');
if (!a) return;
a.addEventListener('click', (e) => {
e.preventDefault();
const go = () => root.OVDrop && root.OVDrop.pages(a, flatLinks(rawLinks()), navIcon, _config.search === false ? null : { label: 'All OpenVibe sites', onClick: openSwitcher });
if (root.OVDrop) return go();
if (document.getElementById('ov-drop-loader')) return;
const sc = document.createElement('script'); sc.id = 'ov-drop-loader'; sc.src = siblingV('ov-drop.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
});
}
function hookSignIn(nav) {
const b = nav.querySelector('#openvibe-login-btn');
if (!b || !b.classList || typeof b.addEventListener !== 'function') return;
b.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; b.classList.add('is-going'); setTimeout(() => b.classList.remove('is-going'), 6000); });
}
// Links leave from the end until the bar holds one row (they stay in the menu); the site's "More" leaves last.
function fitLinks() {
const nav = (this && this.nodeType === 1 ? this : null) || _navEl, box = nav && nav.querySelector && nav.querySelector('.openvibe-navbar-links');
if (!box || !box.children || !nav.clientWidth) return;
const kids = Array.from(box.children);
kids.forEach((k) => k.classList.remove('ovnav-out'));
const more = (k) => !!(k.querySelector && k.querySelector('[data-link-id="ovnav-more"]'));
const order = kids.filter((k) => !more(k)).reverse().concat(kids.filter(more));
for (let i = 0; i < order.length && nav.scrollWidth > nav.clientWidth + 1; i += 1) order[i].classList.add('ovnav-out');
}
let _fitWatch = null, _fitSeen = null;
function hookFit(nav) {
fitLinks.call(nav);
if (typeof ResizeObserver !== 'function' || typeof MutationObserver !== 'function') return;
if (_fitWatch) { _fitWatch.disconnect(); _fitSeen.disconnect(); }
let t = 0;
const soon = () => { clearTimeout(t); t = setTimeout(() => fitLinks.call(nav), 60); };
_fitWatch = new ResizeObserver(soon); _fitWatch.observe(nav);
_fitSeen = new MutationObserver((list) => { if (list.some((m) => !(m.target.closest && m.target.closest('.openvibe-navbar-links')))) soon(); });
_fitSeen.observe(nav, { childList: true, subtree: true, characterData: true });
if (document.fonts && document.fonts.ready && document.fonts.ready.then) document.fonts.ready.then(soon, () => {});
}
function hookSwitcher(nav) {
const b = nav.querySelector('#openvibe-switch-btn, #openvibe-brand-btn');
if (!b) return;
if (b.id === 'openvibe-switch-btn') b.addEventListener('click', () => openSwitcher());
for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });
// Once the page is idle: the visit never competes with the first paint or the sign-in check.
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => recordVisit(), { timeout: 8000 });
else setTimeout(recordVisit, 3000);
}
let _orbIntro = false;
let _searchKeys = false;
function hookSearch(nav) {
const b = nav.querySelector('#openvibe-search-btn');
if (b) {
// An agent that needs the person opens the menu on Agents (the Dock's pill did, 4.220.0).
b.addEventListener('click', () => { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else if (_menuStatus && _menuStatus.tone === 'waiting') openMenuOn('agent'); else openSearch(); });
for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });
if (menuOpen()) b.setAttribute('aria-expanded', 'true');
// One ripple a tab, a moment after the first paint: here is the menu.
let seen = _orbIntro; try { seen = seen || sessionStorage.getItem('ov-orb-intro') === '1'; sessionStorage.setItem('ov-orb-intro', '1'); } catch { /* */ }
// The timers look the orb up when they fire: holding this one would keep a swapped-out bar alive (boost).
const orbsNow = () => (_navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-search-btn, #openvibe-burger.ovnav-orb')] : []);
if (!seen && b.classList) { _orbIntro = true; setTimeout(() => { orbsNow().forEach((o) => o.classList.add('ovo-intro')); setTimeout(() => { orbsNow().forEach((o) => o.classList.remove('ovo-intro')); }, 1100); }, 700); }
}
if (_searchKeys || _config.search === false || typeof document === 'undefined') return;
_searchKeys = true;
document.addEventListener('keydown', (e) => {
const t = e.target, typing = t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
if ((e.key === '/' && !typing && !e.ctrlKey && !e.metaKey && !e.altKey) || ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K'))) { e.preventDefault(); openSearch(); }
});
}
function renderAppBar() {
if (typeof document === 'undefined' || !document.body) return;
const ab = root.OpenVibeAppBar;
if (ab) { ab.render({ config: _config, links: rawLinks(), activePage: _activePage, nav: _navEl, navIcon, escapeAttr, escapeHtml, flatLinks }); return; }
if (!_config.appBar || _appBarLoading) return;
_appBarLoading = true;
try { const sc = document.createElement('script'); sc.src = siblingV('appbar.js'); sc.async = true; sc.onload = () => renderAppBar(); document.head.appendChild(sc); } catch { /* no bar */ }
}
// The bar's at-the-top state (ovnav-top, see injectStyles), on every render and scroll. One window listener pair for
// the page's life, a frame at most per update; --ovnav-real-h is the bar's height for overlay pages.
let _topHooked = false, _lastY = 0;
function syncTop() {
if (!_navEl) return;
const y = root.scrollY || (document.documentElement && document.documentElement.scrollTop) || 0;
_navEl.classList.toggle('ovnav-top', y <= 4);
// Phones: away while reading down, back on the first scroll up; never with one of its menus open or focus in it.
if (_config.autoHide !== false) {
const phone = !!(root.matchMedia && root.matchMedia('(max-width: 760px)').matches);
const busy = !!(_navEl.querySelector('.open, [aria-expanded="true"]') || (document.body && document.body.classList.contains('ov-panel-open')) || menuOpen()
|| (document.activeElement && document.activeElement !== document.body && _navEl.contains(document.activeElement)));
const dy = y - _lastY;
if (!phone || y < 90 || busy) _navEl.classList.remove('ovnav-hide');
else if (dy > 10) _navEl.classList.add('ovnav-hide');
else if (dy < -6) _navEl.classList.remove('ovnav-hide');
}
_lastY = y;
// A fixed bar (a site that positions it itself, like Live) takes no room, so an overlay page needs no pull-up.
const fixed = root.getComputedStyle && root.getComputedStyle(_navEl).position === 'fixed';
const de = document.documentElement;
if (de && de.style) de.style.setProperty('--ovnav-real-h', `${fixed ? 0 : (_navEl.offsetHeight || 52)}px`);
}
function hookTop() {
syncTop();
if (_topHooked || typeof root.addEventListener !== 'function') return;
_topHooked = true;
let raf = 0;
const later = () => { if (!raf) raf = (root.requestAnimationFrame || setTimeout)(() => { raf = 0; syncTop(); }); };
root.addEventListener('scroll', later, { passive: true });
root.addEventListener('resize', later, { passive: true });
}
// The page a link leads to is this page, or this page sits under it (/tasks marks /tasks/42; / marks only /).
const leadsHere = (href, path) => {
let p = null;
try { p = new URL(href, location.href); } catch { return false; }
if (p.origin !== location.origin) return false;
return p.pathname === path || (p.pathname !== '/' && path.startsWith(`${p.pathname.replace(/\/+$/, '')}/`));
};
const remark = (list, path) => list.map((l) => {
const out = l.active === undefined ? l : Object.assign({}, l, { active: leadsHere(l.href || '', path) });
return Array.isArray(l.children) ? Object.assign({}, out, { children: remark(l.children, path) }) : out;
});
// openvibe-shared/boost swapped the page in place: this is a new page for the history and the
// active-link state, and a login from here returns here.
let _boostHooked = false;
function hookBoost() {
if (_boostHooked || typeof document === 'undefined') return;
_boostHooked = true;
document.addEventListener('ov:boost:load', () => {
if (_config.history) _config.history = Object.assign({}, _config.history, { title: document.title, url: location.href });
// A site that marked its links on the server marked them for the page it served; after a swap the mark moves
// to the new page, or the old page's link stays lit beside the new one (Codes, 2026-10-30).
if (Array.isArray(_config.links)) _config.links = remark(_config.links, location.pathname || '/');
try { render(); } catch { /* keep the old bar */ }
if (_config.user) recordHistory();
});
}
// ── Moving between pages (4.70.0, plan L52; the owner, 2026-11-01: "make navigation feel better… the logo changing
// while it loads… smarter loading") ──
// The mark is the loading indicator: it works from the tap until the next page is in (OVMark.navStart/navEnd; a
// single-page app calls OpenVibeNavbar.busy). Same-site pages are fetched ahead on hover or press (speculation rules,
// where the browser has them), never sign-in, sign-out, the API or a download.
let _movesHooked = false;
function hookMoves() {
if (_movesHooked || typeof document === 'undefined' || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
_movesHooked = true;
// A boosted site prefetches through boost.js already (and Chrome's prefetch beside it kept parsed pages alive).
if (_config.speculate !== false && !root.OVBoost && !document.querySelector('meta[name="ov-boost"]') && typeof HTMLScriptElement !== 'undefined' && HTMLScriptElement.supports && HTMLScriptElement.supports('speculationrules') && !document.querySelector('script[type="speculationrules"]')) {
try {
const sr = document.createElement('script');
sr.type = 'speculationrules';
// A page whose policy names a nonce (Chat, Search…) runs inline speculation rules only with it: Chrome 156
// ignores 'inline-speculation-rules' beside a nonce (2026-11-02). The page's own nonce is read from any of
// its nonced scripts (the attribute is blanked once parsed; the property keeps it).
const nonce = [].slice.call(document.scripts || []).map((x) => x.nonce).find(Boolean);
if (nonce) sr.nonce = nonce;
// The rules (the owner, 2026-11-09: "how come when you change sites it doesn't load more asynchronously and
// dynamically, so it's faster across sites?"): a same-site page is prefetched, and — where the browser has
// prerendering — rendered in the background before the click lands; a link to another OpenVibe site is
// prefetched too, so the next site is already warm when the person arrives.
const sameSite = { and: [{ href_matches: '/*' }, { not: { href_matches: ['/auth/*', '/api/*', '/logout*', '/*logout*', '/signout*', '/internal/*', '/*.zip', '/*.pdf'] } }, { not: { selector_matches: '[data-no-prefetch], [download], [rel~=nofollow]' } }] };
const rules = { prefetch: [{ where: sameSite, eagerness: 'moderate' }] };
// Prerendering a page the site's own router draws would be work thrown away: a site says no with
// prerender: false, and marks the links it draws itself with data-no-prefetch.
if (_config.prerender !== false) rules.prerender = [{ where: sameSite, eagerness: 'moderate' }];
// Another OpenVibe site: the browser fetches it when the pointer arrives (Chrome leaves a site alone when
// the person has cookies on it, which is right: that page is theirs, not a public copy).
rules.prefetch.push({ where: { selector_matches: '[data-ov-net-link]' }, eagerness: 'moderate' });
sr.textContent = JSON.stringify(rules);
document.head.appendChild(sr);
} catch { /* an old browser: links load as they always did */ }
}
// 4.229.0 (the owner, 11-12: "when you click links and pages and other domains within the menu or navigation it
// loads async dynamically and is highly optimized"): a link to another OpenVibe site warms up as a finger lands on
// it or a pointer rests on it (its connection opens: DNS and TLS while the press is still down). This site's
// pages are already fetched ahead by the speculation rules above; browsers do not prefetch another site's page
// for a person signed in there. Anywhere on the page: the bar, the menu, a footer, a card. At most 24 origins.
const warmed = new Set();
const OV_HOST = /(^|\.)(openvibe\.[a-z]+|openre\.stream|open\.shoes)$/;
const warm = (a) => {
let u; try { u = new URL(a.getAttribute('href'), location.href); } catch { return; }
if (u.protocol !== 'https:' || u.origin === location.origin || warmed.has(u.origin) || warmed.size >= 24 || !OV_HOST.test(u.hostname)) return;
warmed.add(u.origin);
try { const l = document.createElement('link'); l.rel = 'preconnect'; l.href = u.origin; document.head.appendChild(l); } catch { /* the click connects */ }
};
document.addEventListener('pointerdown', (e) => { const a = e.target && e.target.closest && e.target.closest('a[href]'); if (a) warm(a); }, { capture: true, passive: true });
let hoverTimer = 0;
document.addEventListener('pointerover', (e) => {
if (e.pointerType === 'touch') return;
const a = e.target && e.target.closest && e.target.closest('a[href]'); if (!a) return;
clearTimeout(hoverTimer); hoverTimer = setTimeout(() => warm(a), 90);
}, { passive: true });
// A plain same-site link on a server-rendered page: the mark starts working at once; the next page's mark takes over.
document.addEventListener('click', (e) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target && e.target.closest && e.target.closest('a[href]');
if (!a || a.target === '_blank' || a.hasAttribute('download')) return;
let u; try { u = new URL(a.href, location.href); } catch { return; }
if (u.origin !== location.origin || (u.pathname === location.pathname && u.search === location.search)) return;
setTimeout(() => { if (!e.defaultPrevented) busy(true); }, 0); // after the page's own handler (an SPA's router) had its say
}, true);
// Back from the browser's page cache: whatever was loading is done.
window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) { busy(false); restoreMenu(ev); } });
document.addEventListener('openvibe-menu-restore', () => restoreMenu({ persisted: true }));
setTimeout(() => restoreMenu(null), 0);
setTimeout(menuFromHash, 0);
window.addEventListener('hashchange', menuFromHash);
document.addEventListener('click', (e) => {
const t = e.target && e.target.closest ? e.target.closest('[data-ov-menu]') : null;
if (!t || e.defaultPrevented || e.button !== 0) return;
// A link that also has an address keeps the browser's own ways to open it elsewhere (a new tab, a window).
if (t.matches('a[href]') && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) return;
e.preventDefault();
openMenuAt(t.getAttribute('data-ov-menu'), t);
});
}
// ── The shared worker (openvibe-sw.js): the shell's cache, and the push notifications it always carried ──
// A site opts in: OpenVibeNavbar.init({ sw: true }). The worker is the same file the notification bell registers for
// push, so there is one registration at scope "/" and nothing is unregistered by accident.
let _workerHooked = false;
function hookWorker() {
const spec = _config.sw;
if (_workerHooked || !spec) return;
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
if (typeof window !== 'undefined' && window.isSecureContext === false) return;
_workerHooked = true;
const opts = (spec === true) ? {} : (spec || {});
const url = opts.path || '/openvibe-sw.js';
const scope = opts.scope || '/';
// After the load: registering early would compete with the page's own files for the network.
const go = () => { try { navigator.serviceWorker.register(url, { scope }).catch(() => { /* no worker, no cache */ }); } catch { /* */ } };
if (document.readyState === 'complete') go();
else window.addEventListener('load', go, { once: true });
}
let _busyTimer = 0, _busyOn = false;
function busy(on) {
clearTimeout(_busyTimer);
if (!on && !_busyOn) return; // only ends what it started (the mark keeps nothing it was not asked to)
_busyOn = !!on;
// A link that turns out to be a download never leaves the page: the mark gives up after ten seconds.
if (on) _busyTimer = setTimeout(() => busy(false), 10000);
try { if (window.OVMark) { if (on) window.OVMark.navStart(); else window.OVMark.navEnd(); } } catch { /* */ }
}
const OpenVibeNavbar = {
/** 4.228.0: the person's Network token the site gave init() (or null), for Shared's own calls to the Network
* (the composer's settings sync and Caption it). The site already put it in the page; this only reads it. */
token() { return _config.token || null; },
/** The mark works while something loads (an SPA's route, a long action): busy(true), then busy(false). */
busy(on) { busy(!!on); },
/** A floating card (overlay.js): OpenVibeNavbar.overlay({ id, src, title, href, live, side }) → Promise. */
overlay(desc) { return new Promise((ok) => loadOverlay((O) => ok(O.open(desc)))); },
/** The overlay host itself, loaded (for declare / close / list): Promise. */
overlays() { return new Promise((ok) => loadOverlay(ok)); },
init(opts = {}) {
Object.assign(_config, opts);
// Balances and Messages on every site unless it says false or brings its own (4.130.0).
if (opts.wallet === undefined) _config.wallet = true;
if (opts.messages === undefined) _config.messages = { __default: true };
injectStyles();
if (!root.OVSurfaces && !document.getElementById('ov-surfaces-loader')) {
const sc = document.createElement('script'); sc.id = 'ov-surfaces-loader'; sc.src = siblingV('surfaces.js'); sc.async = true; document.head.appendChild(sc);
}
hookBoost();
hookMoves();
hookWorker();
const el = render();
upgradeIconsWhenFontsReady();
if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
if (typeof document !== 'undefined') setTimeout(warmMenuLater, 3000);
// A page that passes a user keeps control; otherwise the user comes from the shared SSO state, then a re-render.
// auth: 'external' — the site signs people in itself (Live) and tells us with setUser().
if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
else if (!_config.user) refreshAuthState();
else { recordHistory(); enableHandoff(); }
startQuestGuide();
return el;
},
/** Record something the signed-in user did here ({type, title, url, icon}) in their network history. */
record(entry) { const h = _config.history; _config.history = Object.assign({}, h || {}, entry || {}); recordHistory(); _config.history = h; },
/** Re-resolve the signed-in user from the shared SSO state. */
refreshAuth() { return refreshAuthState(); },
/** Update user (after account switch). */
setUser(user) {
_config.user = user;
render();
},
/** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */
setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
/** Open the search panel (search.js), with an optional query already in it. */
openSearch(q, tab) { openSearch(q, tab); },
openSwitcher() { openSwitcher(); },
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
/** New messages: the count joins the bell's (there is no Messages button); a spec keeps `messages` on or off. */
setMessages(count, spec) {
if (spec !== undefined) _config.messages = spec;
_msgCount = Math.max(0, Number(count) || 0);
const N = root.OpenVibeNotifications;
if (N && typeof N.setMessages === 'function') N.setMessages(_msgCount);
},
/** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
addMenuItem(item) {
if (!item || !item.label) return;
const it = Object.assign({ id: item.id || `mi-${Math.random().toString(36).slice(2, 8)}` }, item);
(it.position === 'before' ? _runtimeMenu.before : _runtimeMenu.after).push(it);
if (_navEl && _config.user) render();
return it.id;
},
removeMenuItem(id) {
for (const k of ['before', 'after']) _runtimeMenu[k] = _runtimeMenu[k].filter(i => i.id !== id);
if (_navEl && _config.user) render();
},
/** Is this browser signed in to the network? ({ signedIn, username } or null when unknown). */
checkNetworkSession,
/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
brand() { return resolveBrand(); },
/** The signed-in user changed (sites with auth: 'external'). Pass null for signed out. */
openWallet,
refreshWallet() { _walletAt = 0; loadWalletChips(); },
setUser(user, token) { if (root.OpenVibeWallet && (_config.user !== user || (token !== undefined && token !== _config.token))) root.OpenVibeWallet.close(); _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; const result = render(); startQuestGuide(); return result; },
/** Single-page apps: mark the link whose `page` matches as active. */
setActive(page) { _activePage = page || null; renderAppBar(); if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); },
/** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */
setChip(id, value, patch) {
const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];
lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));
document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; });
document.querySelectorAll(`[data-chip-mini="${id}"] .ovnav-chip-mv`).forEach(el => { el.textContent = shortValue(value); });
if (root.OpenVibePurse) root.OpenVibePurse.set(id, value);
document.querySelectorAll(`[data-chip-row="${id}"] b`).forEach(el => { el.textContent = value == null ? '' : String(value); });
const all = document.getElementById('openvibe-chips-all');
if (all) all.setAttribute('aria-label', `Your balances: ${(_config.chips || []).filter((c) => c && !c.hidden).map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', ')}`);
},
/** Patch a top-level or dropdown link by id: updateLink('broadcast', { hidden: false, dot: true, label }). */
updateLink(id, patch) {
const list = _runtimeLinks || _config.links || [];
const l = flatLinks(list).find(x => x && x.id === id); if (!l) return; Object.assign(l, patch || {});
if (!_navEl) return;
_navEl.querySelectorAll(`[data-link-id="${id}"]`).forEach(a => {
if ('hidden' in patch) { a.hidden = !!patch.hidden; const dd = a.parentElement; if (dd && dd.classList.contains('ovnav-dd')) dd.hidden = !!patch.hidden; }
if ('label' in patch) { const t = a.querySelector('.ovnav-l'); if (t) t.textContent = patch.label; }
if ('href' in patch) a.setAttribute('href', patch.href);
if ('dot' in patch) { const d = a.querySelector('.ovnav-dot'); if (d) d.hidden = !patch.dot; }
});
},
/** Patch a custom menu item by id: updateMenuItem('admin', { hidden: false, value: '3' }). */
updateMenuItem(id, patch) {
const item = sectionItems(_config.menu && _config.menu.sections).find(x => x && x.id === id); if (item) Object.assign(item, patch || {});
document.querySelectorAll(`.openvibe-navbar-dropdown [data-menu-id="${id}"]`).forEach(el => { if ('hidden' in patch) el.hidden = !!patch.hidden; if ('value' in patch) { const v = el.querySelector('.ud-val'); if (v) v.textContent = patch.value; } });
},
/** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */
get activity() { return root.OpenVibeIsland || null; },
set activity(_v) { /* island.js binds itself; nothing to store */ },
setToken(token) { _config.token = token; },
/** Who is signed in and the token for Network (read only): for the files loaded beside the navbar (ov-labels.js). */
get session() { return { user: _config.user || null, token: _config.token || null, apiBase: _config.apiBase }; },
/** Give every box that scrolls with nothing focusable inside it tabindex="0" now (the bar does it when the page
* settles; a page that draws such a box later may call this). Returns how many it marked. */
focusScrollers() { try { return focusScrollers(); } catch { return 0; } },
/** A button before the brand: { icon | path, label, controls, sidebar, phoneOnly, pressed, href, onClick }; null removes it. README: Navbar slots. */
lead(spec) {
_lead = spec ? { ...spec } : null;
paintSlots();
return { set(patch) { if (_lead) { Object.assign(_lead, patch || {}); paintSlots(); } }, remove() { _lead = null; paintSlots(); } };
},
/** An icon button before the bell: lead()'s spec plus id and badge. */
action(spec) {
if (!spec || !spec.id || !/^[a-z0-9-]{1,40}$/.test(spec.id)) throw new Error('OpenVibeNavbar.action needs an id: a-z, 0-9 and -');
_actions.set(spec.id, { ...spec });
paintSlots();
return { set(patch) { const a = _actions.get(spec.id); if (a) { Object.assign(a, patch || {}); paintSlots(); } }, remove() { _actions.delete(spec.id); paintSlots(); } };
},
/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
/** The menu at an address: a place, or place/part ('you/notifications', 'you/apps', 'vibe'; 4.223.0, parts 4.229.0).
* The same address works as data-ov-menu="…" on any element and as #ov-menu=… at the end of a page's address. */
openMenu(p, fromEl) { openMenuAt(p || 'you', fromEl); },
/** What an address opens: { door } or { tab, part } (for a site's own button that wants to show where it goes). */
menuAddress,
pop: popFrom,
unpop,
askAgent(text) { askAgent(text); },
/** Get the bell mount point for OpenVibeNotifications. */
getBellMount() {
return _navEl?.querySelector('#openvibe-bell-mount') || null;
},
getElement() { return _navEl; },
destroy() {
_navEl?.remove();
_navEl = null;
},
/** Internal auth helpers — exposed for tests, not a public API. */
_auth: {
parseCookieToken,
onToolsDomain,
onNetworkDomain,
resolveLoginHref,
resolveToken,
resolveSessionUser,
clearAuthState,
persistToken,
fillUrl,
},
_ask: { selectedText, askVibeHref },
_wallet: { loadWallet, walletOrder, syncWalletChips, loadWalletChips, configure: (c) => { Object.assign(_config, c); _walletLoaded = false; } },
_defaults: { linksFor },
};
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
else root.OpenVibeNavbar = OpenVibeNavbar;
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);