/** * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js. * * OpenVibeFooter.init({ service, variant, links, apiBase, mount, pulse }) * OpenVibeFooter.setVariant('full' | 'compact' | 'auto') the page's own variant (SPA route changes) * OpenVibeFooter.claim('compact' | 'full', key) → release(): a part of the page asks for a variant while it needs * it (Live's channel while its stream is live: compact; offline: * the page's own full). Compact wins over full, any claim over the * page's own; releasing a claim brings back what is left. * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR * * Why this exists: every property was hand-rolling its own footer, so the network links, the * legal links and the styling drifted apart, and a site that gained a new sibling had to be * edited by hand. Here the site passes only the links that are *its own*; the Network column, * the Legal column, the signed-in row and all the styling come from this module, so adding a * property or changing a policy link updates every site at once. * * Variants: * auto full on the site's front page (and the paths in fullOn), compact everywhere else: a site looks like an app * once someone is in it (the owner, 2026-10-28: "the full-size footer on the chat pages might be a little * much … if we want to have that app-like design … make it a little more sleeker across sites"). On the * server, ssr({ variant: 'auto', path }) picks with the request's path (no path: compact). * full the multi-column footer, for home and browse pages * compact one quiet line, for pages where the footer is not the point — a streamer's * channel, a player, a tool someone is mid-task in. "More from OpenVibe" opens the * whole footer in place (a
: no script needed, and every link stays in the * page for crawlers, so a player page links the network as a home page does) * * The pulse line (full, and an opened compact one): what is happening on the network right now — * streams live, people watching, open sites, last week's views — from openvibe.network/api/frame/pulse, * read once the footer scrolls into view and every minute while it stays there; numbers only. * * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools * hub, so the network behaves like one product instead of a ring of separate sites. It reads * the shared ov_token the same way navbar.js does and never blocks rendering on it. * * The page's end (plan 280, the owner: "That huge wasted space … in between that and the footer … It's just waste"): * content ends, then 32 px on a computer (24 on a phone), then the footer — the footer's own margin-top is that * number and nothing else may add to it. A site's bottom padding above the footer is its own dead space and is * removed in that site (scripts/audit-page-end.js measures it on every home). */ (function (root, factory) { const api = factory(); if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR) if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser })(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {}); // Where this file was served from: shipped.js is loaded from the same place (the site's own // /shared/ copy, or openvibe.network), so the site's CSP already allows it. const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })(); // ── Frame data (https://openvibe.network/api/frame) ───────────────────────────── // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30 // minutes in localStorage and refreshed in the background, so pages paint from cache and the // order never jumps while someone is looking at it. Defined once, shared by navbar + footer. const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () { const KEY = 'ov_frame_v2', TTL = 30 * 60000; // v2: every site, with its launcher group let inflight = null; const host = () => (typeof location !== 'undefined' ? location.hostname : ''); const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } }; function clean(d) { if (!d || !Array.isArray(d.nav)) return null; const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80), group: String(l.group || '').slice(0, 20) } : null); const f = d.footer || {}, lg = f.legal || {}; return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24), footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10), legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } }; } function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } } function refresh() { if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call out if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null); inflight = fetch('https://openvibe.network/api/frame?host=' + encodeURIComponent(host()), { credentials: 'omit' }) .then(r => (r.ok ? r.json() : null)).then(clean) .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; }) .catch(() => null); return inflight; } /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */ function get(onFirst) { const c = cached(); if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); } return c ? c.data : null; } return { get, refresh }; })()); const NETWORK_URL = 'https://openvibe.network'; const REPORT_URL = 'https://openvibe.help/tickets/new'; const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // The eight groups, in the menu's order (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of // 2026-11-10). The Network column links each group's section on /sites; the Frame's `groups` replaces these names // and order when it answers, so the footer, the launcher and the home pages read the same list. const GROUP_LINKS = [ { id: 'watch', name: 'Watch & stream' }, { id: 'talk', name: 'Talk & belong' }, { id: 'play', name: 'Play' }, { id: 'read', name: 'Read & ask' }, { id: 'shop', name: 'Shop & money' }, { id: 'tools', name: 'Free tools' }, { id: 'make', name: 'Make with AI' }, { id: 'build', name: 'For developers' }, ]; const groupHref = (id) => `${NETWORK_URL}/sites#group-${encodeURIComponent(id)}`; const NETWORK = GROUP_LINKS.map(({ id, name }) => ({ id, name, url: groupHref(id), desc: '' })); // Tool subdomains have no site of their own; brandFor names them ".OpenVibe.Tools". const TOOL_FAMILIES = new Set(['dev', 'net', 'img', 'audio', 'docs', 'text', 'yt']); const LEGAL = [ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' }, { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' }, { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' }, ]; // "Report a problem" on every site: a ticket on openvibe.help with this site and page filled in (OpenVibe.Help#81). // The page is the origin and path only, since a query or fragment can carry a sign-in code or a token. Rendered on // the server the page is unknown, so a click fills it in at that moment (a single-page app's route counts). function reportHref() { try { if (typeof location === 'undefined' || !/^https?:$/.test(location.protocol)) return REPORT_URL; return `${REPORT_URL}?${new URLSearchParams({ site: location.hostname.replace(/^www\./, ''), page: (location.origin + location.pathname).slice(0, 500) })}`; } catch { return REPORT_URL; } } const reportLink = () => `Report a problem`; const SOCIAL = [ { name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' }, { name: 'Discord', icon: 'discord', url: 'https://discord.gg/M6MuRUaeJj' }, ]; const DEFAULTS = { service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted) brandName: null, tagline: 'Open source and community run. Built in the open by the people using it.', variant: 'full', // 'full' | 'compact' | 'auto' fullOn: ['/'], // with variant 'auto': the paths that get the full footer mount: '#ov-footer', links: [], // [{ heading, items: [{ name, url, onclick? }] }] legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site apiBase: NETWORK_URL, user: null, showNetwork: true, showAccount: true, sitemap: '/sitemap.xml', shipped: true, // the "shipped X ago" line (openvibe-shared/shipped.js) pulse: true, // the network-right-now line (openvibe.network/api/frame/pulse) updates: null, // this site's update log; default: openvibe.network/updates?site= }; let _cfg = { ...DEFAULTS }; let _mounted = null; const _claims = new Map(); // key → 'compact' | 'full' (claim()) /** 'auto' for a path: full on the front page and the paths in fullOn, compact elsewhere (no path: compact). */ function autoFor(path, fullOn) { if (typeof path !== 'string' || !path) return 'compact'; const p = path.replace(/[?#].*$/, '').replace(/(.)\/+$/, '$1') || '/'; return (Array.isArray(fullOn) && fullOn.length ? fullOn : ['/']).includes(p) ? 'full' : 'compact'; } const herePath = () => (typeof location !== 'undefined' && location.pathname) || '/'; /** The variant shown: a compact claim, else a full one, else the page's own ('auto' read from where the page is). */ function shownVariant(base) { const asked = [..._claims.values()]; if (asked.includes('compact')) return 'compact'; if (asked.includes('full')) return 'full'; if (base === 'auto') return autoFor(herePath(), _cfg.fullOn); return base === 'compact' ? 'compact' : 'full'; } function detectService() { if (typeof location === 'undefined') return 'network'; const h = location.hostname; if (h.endsWith('openvibe.live')) return 'live'; if (h.endsWith('openvibe.network')) return 'network'; if (h.endsWith('openvibe.games')) return 'games'; if (/(^|\.)openre\.stream$/.test(h)) return 'openre'; const tld = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h); if (tld && tld[1] !== 'tools' && TLD_LABELS[tld[1]]) return tld[1]; const sub = h.replace(/\.openvibe\.tools$/, ''); return sub === h ? 'tools' : (sub || 'tools'); } const TLD_LABELS = { live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games', community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons' }; /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */ function brandFor(service) { // The navbar already resolved the spelling (MergePDF, JSON, Pastes…): reuse it so the two // never disagree on the same page. try { const nb = typeof window !== 'undefined' && window.OpenVibeNavbar && window.OpenVibeNavbar.brand && window.OpenVibeNavbar.brand(); if (nb && nb.name) return nb.name; } catch { /* */ } const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : ''; const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/); if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); } if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRestream'; if ((m = h.match(/^(?:(.+)\.)?open\.([a-z]+)$/))) return `Open.${TLD_LABELS[m[2]] || cap(m[2])}`; if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`; if (TOOL_FAMILIES.has(service)) return `${cap(service)}.OpenVibe.Tools`; return 'OpenVibe'; } function legalHref(item, cfg) { // Each site answers for itself: the Frame service names this domain's own documents. const frame = cfg._frame; if (frame && frame.footer.legal && !cfg.legalBase) return frame.footer.legal[item.key]; // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing. if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path; if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path; return item.fallback; } // ── Icons ────────────────────────────────────────────────── // The footer ships its own icons. It used to borrow the page's Font Awesome, so on pages that do not // load that font (the server-rendered Tools and legal pages) every icon was an empty box. const ICONS = { github: '', discord: '', sitemap: '', arrow: '', channel: '', dashboard: '', account: '', themes: '', tools: '', // 4.188.0: the strip's icons, drawn by openvibe-shared/glyphs.js (the same hand) for the counts and the // items a site declares. A name the set does not carry falls back to the arrow above. members: '', video: '', clipboard: '', image: '', news: '', clapper: '', film: '', game: '', live: '', chart: '', star: '', sites: '', }; const icon = (name) => ``; // ── Markup ───────────────────────────────────────────────── function linkTag(raw) { // Items are { name, url }; { label, href } (the navbar's shape) is read the same, so a site that // passes it does not get empty links. const item = { ...raw, name: raw.name ?? raw.label, url: raw.url ?? raw.href }; // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one. const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url); const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : ''; const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : ''; const title = item.desc ? ` title="${esc(item.desc)}"` : ''; return `${esc(item.name)}`; } /** The "🚀 shipped X ago: …" line, filled by shipped.js from the network changelog; hidden until then. */ function shippedLine(c) { if (c.shipped === false) return ''; const host = typeof location !== 'undefined' ? location.hostname : ''; const updates = c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`; return ``; } function updatesHref(c) { const host = typeof location !== 'undefined' ? location.hostname : ''; return c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`; } function buildHTML(cfg) { const c = { ...DEFAULTS, ...(cfg || {}) }; const service = c.service || detectService(); const brand = c.brandName || brandFor(service); const frame = c._frame = OVFrame.get(() => { try { render(); } catch { /* */ } }); // The Frame's own groups, in its order, when it answers; the built-in eight otherwise. One column of groups, // not a head-list of sites, so the footer shows the same neighbourhoods as the launcher and the home pages. const network = frame && Array.isArray(frame.groups) && frame.groups.length ? frame.groups.map(g => ({ name: g.name, url: groupHref(g.id), desc: '' })) : NETWORK; // The ranked sites are still what the discover picks must not repeat. const nav = frame && frame.nav.length ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)) : []; const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline; const popular = frame ? frame.footer.popular : []; const discover = frame ? frame.footer.discover.filter(d => !nav.slice(0, 8).some(n => n.url === d.url)) : []; const year = new Date().getFullYear(); if (c.variant === 'compact') { return `
${esc(brand)} ${shippedLine(c)} Open source · ${year}
More from OpenVibe${fullBody(c, { network, popular, discover, brand, tagline, compact: true })}
`; } return fullBody(c, { network, popular, discover, brand, tagline }); } /** The multi-column footer: the brand, the site's own links, the network, popular tools, legal, the account row and the bar. */ function fullBody(c, { network, popular, discover, brand, tagline, compact = false }) { // Each link column is a fold: open on a desktop (its heading reads and behaves as a heading), folded on a phone // (a 48 px row with a chevron), so a phone's footer is about one screen shorter. Every link stays in the page. // The fold's line carries its count ("9 links"), so a folded column still says how much it holds (#208). const fold = (label, heading, body, count) => ` `; // A column needs a name and at least one link: an untitled "0 links" fold is never drawn (4.202.0's screenshots // showed two when a site passed empty groups). const cols = (c.links || []).filter(group => group && String(group.heading || '').trim() && Array.isArray(group.items) && group.items.length) .map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''), group.items.length)).join(''); const networkLinks = network.slice(0, 8); return `
${c.pulse === false ? '' : ''}
${esc(brand)}

${esc(tagline)}

${SOCIAL.map(s => `${icon(s.icon)}`).join('')} ${icon('sitemap')}
${cols} ${c.showNetwork ? fold('The OpenVibe network', 'Network', `${networkLinks.map(linkTag).join('')} Everything else ${icon('arrow')}`, networkLinks.length + 1) : ''} ${popular.length ? fold('Popular tools', 'Popular tools', `${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')} All tools ${icon('arrow')}`, Math.min(popular.length, 6) + Math.min(discover.length, 2) + 1) : ''} ${fold('Legal', 'Legal', `${LEGAL.map(l => `${esc(l.name)}`).join('')} ${reportLink()}`, LEGAL.length + 1)}
${compact ? '' : `
Open source & community driven · built in the open ${shippedLine(c)} ${c.shipped === false ? '' : `Updates`} ${LEGAL.map(l => `${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}`).join('')}
`}
`; } // ── Signed-in row: the same account, wherever you are ────── function accountHTML(user, service) { const name = esc(user.display_name || user.username || 'you'); // An openvibe.media object picture comes in sizes (Media's /o/:id?w=&fmt=): 64 px as WebP for the 24 px avatar. // Without one in the record, the Network's /avatar/: the picture the account has now (navbar.js, the same rule). const media = /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]{10,40}$/.test(user.avatar_url || ''); const sized = media ? `${user.avatar_url}?w=64&fmt=webp` : (user.username && !user.is_anon ? `${NETWORK_URL}/avatar/${encodeURIComponent(user.username)}?s=32` : ''); const avatar = sized ? `` : `${esc(String(name).charAt(0).toUpperCase())}`; const quick = [ service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' }, { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'account' }, { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' }, { name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' }, ]; return ` `; } function storedToken() { try { const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/); return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null; } catch { return null; } } async function fillAccount(cfg) { const host = document.getElementById('ovf-account'); if (!host || !cfg.showAccount || cfg.variant === 'compact') return; let user = cfg.user; if (!user) { const token = storedToken(); if (!token) return; try { const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' }); if (!res.ok) return; const data = await res.json(); user = data.user || data; } catch { return; } } if (!user || !user.username) return; host.innerHTML = accountHTML(user, cfg.service || detectService()); host.hidden = false; } // ── Styles (injected once) ───────────────────────────────── const CSS = ` .ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:32px;font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));} .ovf[data-variant="compact"]{margin-top:32px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));} .ovf-inner{max-width:1240px;margin:0 auto} .ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center} .ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start} .ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0} .ovf-col h3{margin:0 0 2px;font-size:.75rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)} .ovf-fold>summary{list-style:none;cursor:default;pointer-events:none} .ovf-fold>summary::-webkit-details-marker{display:none} .ovf-fold-body{display:flex;flex-direction:column;gap:8px;margin-top:8px} .ovf-fold-n{display:none} .ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s} .ovf-col a:hover{color:var(--accent,#60a5fa);transform:translateX(2px)} @media (pointer:coarse){.ovf-col a{display:inline-flex;align-items:center;min-height:44px}.ovf-col .ovf-social a{width:44px;height:44px}.ovf-legal-inline a,.ovf-compact-links a{position:relative}.ovf-legal-inline a::after,.ovf-compact-links a::after{content:'';position:absolute;left:-5px;right:-5px;top:-15px;bottom:-15px}} .ovf-more{font-weight:600;opacity:.85}.ovf-ic{flex:none;vertical-align:-.14em}.ovf-more .ovf-ic{font-size:.85em;margin-left:3px;transition:transform .15s}.ovf-more:hover .ovf-ic{transform:translateX(3px)}.ovf-social .ovf-ic{font-size:17px}.ovf-account-links .ovf-ic{font-size:1.05em;opacity:.85} .ovf-brand{position:relative;display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-family:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;font-size:1.12rem;font-weight:800;letter-spacing:-.1px;text-decoration:none} .ovf-brand--sm{font-size:.9rem} .ovf-brand::after{content:'';position:absolute;inset:-6px 0} .ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch} .ovf-social{display:flex;gap:8px;margin-top:4px} .ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2); background:var(--bg-tertiary,#1a1b28);border:1px solid var(--border,rgba(255,255,255,.08));font-size:.95rem;transition:color .15s,border-color .15s,transform .15s} .ovf-social a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-2px)} .ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px; margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px; background:color-mix(in srgb, var(--accent,#60a5fa) 8%, var(--bg-tertiary,#1a1b28)); border:1px solid color-mix(in srgb, var(--accent,#60a5fa) 26%, var(--border,rgba(255,255,255,.08))); animation:ovfIn .4s ease-out both} @keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} .ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)} .ovf-account-who strong{color:var(--text-primary,#f1f4fb)} .ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center; background:var(--accent-strong,var(--accent,#3472d8));color:var(--on-accent-strong,var(--on-accent,#fff));font-size:.7rem;font-weight:800;flex:none} .ovf-avatar img{width:100%;height:100%;object-fit:cover} .ovf-account-links{display:flex;flex-wrap:wrap;gap:6px} .ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600; color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08)); text-decoration:none;transition:color .15s,border-color .15s,transform .15s} .ovf-account-links a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-1px)} .ovf-account-links i{font-size:.72rem;opacity:.8} .ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px; margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08)); padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)} .ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem} .ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px} .ovf-compact-links{justify-content:center} .ovf [hidden],.ovf-account[hidden]{display:none!important} .ovf .ovf-shipped.ov-shipped-latest{font-size:.76rem;padding:4px 12px;max-width:min(520px,100%);background:transparent} @media (pointer:coarse){.ovf .ovf-shipped.ov-shipped-latest{min-height:44px;padding-top:12px;padding-bottom:12px;box-sizing:border-box}} .ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s} .ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)} .ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.75rem} .ovf-expand{max-width:1240px;margin:8px auto 0} .ovf[data-variant="compact"]{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;column-gap:18px} .ovf[data-variant="compact"]>.ovf-inner--compact{flex:0 1 auto;min-width:0;margin:0} .ovf[data-variant="compact"]>.ovf-expand{margin:0} .ovf[data-variant="compact"]>.ovf-expand:not([open])>summary{padding:0;border:0;border-radius:0;font-size:.78rem;font-weight:400;line-height:inherit} .ovf[data-variant="compact"]>.ovf-expand[open]{flex:1 1 100%;margin:10px auto 0} .ovf-expand>summary{list-style:none;cursor:pointer;width:fit-content;margin:0 auto;padding:4px 12px;border-radius:999px;font-size:.76rem;font-weight:600;color:var(--text-muted,#8b93ad);border:1px solid var(--border,rgba(255,255,255,.08));transition:color .15s,border-color .15s} .ovf-expand>summary::-webkit-details-marker{display:none} .ovf-expand>summary::after{content:"";display:inline-block;width:.42em;height:.42em;margin-left:.5em;border:solid currentColor;border-width:0 1.6px 1.6px 0;transform:translateY(-.15em) rotate(45deg);transition:transform .2s} .ovf-expand[open]>summary::after{transform:translateY(.05em) rotate(-135deg)} .ovf-expand>summary:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa)} .ovf-expand[open]>summary{margin-bottom:clamp(16px,2.5vw,24px)} .ovf-inner--expanded{animation:ovfIn .3s ease-out both} .ovf-pulse{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin:0 0 clamp(18px,2.5vw,24px);padding:9px 14px;border-radius:12px;font-size:.8rem;color:var(--text-secondary,#b6bdd2); background:color-mix(in srgb, var(--accent,#60a5fa) 6%, var(--bg-tertiary,#1a1b28));border:1px solid var(--border,rgba(255,255,255,.08));animation:ovfIn .4s ease-out both} .ovf-pulse a{color:inherit;text-decoration:none;font-weight:600} .ovf-now{flex:1 1 100%;min-width:0;display:grid;gap:8px} .ovf-who>summary{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 9px;min-height:36px;color:var(--text-primary,#f1f4fb)} .ovf-who>summary{cursor:pointer;border-radius:10px} .ovf-who>summary::-webkit-details-marker{display:none} .ovf-who>summary::after{content:"";width:.45em;height:.45em;margin:0 4px 3px auto;border:solid var(--text-muted,#8b93ad);border-width:0 1.8px 1.8px 0;transform:rotate(45deg);transition:transform .2s} .ovf-who[open]>summary::after{transform:translateY(3px) rotate(-135deg)} .ovf-who>summary:focus-visible{outline:2px solid var(--accent,#60a5fa);outline-offset:3px} .ovf-who-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--success-text,#34d399);box-shadow:0 0 0 3px color-mix(in srgb,var(--success-text,#34d399) 22%,transparent);animation:ovf-on 2.4s ease-in-out infinite} @keyframes ovf-on{50%{box-shadow:0 0 0 calc(3px + 3px*var(--ov-move,1)) color-mix(in srgb,var(--success-text,#34d399) 6%,transparent)}} .ovf-who-n{font-weight:500}.ovf-who-n b{font-size:1.05em;font-weight:800;font-variant-numeric:tabular-nums} .ovf-who-when{margin:6px 2px 0;color:var(--text-muted,#8b93ad)} .ovf-who-faces{display:inline-flex;padding-left:7px} .ovf-who-faces img,.ovf-who-faces i{width:24px;height:24px;margin-left:-7px;border-radius:50%;box-shadow:0 0 0 2px var(--bg-secondary,#0f1420);object-fit:cover} .ovf-who i{display:grid;place-items:center;font-style:normal;font-size:.72rem;font-weight:800;color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--accent,#3b82f6) 38%,var(--bg-secondary,#0f1420))} .ovf-who-list{list-style:none;display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0;padding:0;animation:ovf-list .3s cubic-bezier(.2,.9,.3,1.1) both} @keyframes ovf-list{from{opacity:0;transform:translateY(calc(-6px*var(--ov-move,1)))}} .ovf-who-list a{display:inline-flex;align-items:center;gap:7px;min-height:36px;box-sizing:border-box;padding:3px 12px 3px 4px;border-radius:999px;color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--text-primary,#f1f4fb) 6%,transparent);transition:background-color .15s,transform .12s} .ovf-who-list a:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--text-primary,#f1f4fb)} .ovf-who-list a:active{transform:scale(.96)} .ovf-who-list img,.ovf-who-list i{flex:none;width:26px;height:26px;border-radius:50%;object-fit:cover} .ovf-who-list small{color:var(--text-muted,#8b93ad);font-weight:500} .ovf-who-more{margin:8px 2px 0;color:var(--text-muted,#8b93ad)} .ovf-tiles{display:flex;gap:6px;margin:0 -14px;padding:0 14px 2px;overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:none} .ovf-tiles::-webkit-scrollbar{display:none} .ovf-tile{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:36px;box-sizing:border-box;padding:0 12px 0 10px;border-radius:999px;white-space:nowrap;font-weight:500!important;color:var(--text-secondary,#b6bdd2);background:color-mix(in srgb,var(--text-primary,#f1f4fb) 5%,transparent);transition:background-color .15s,transform .12s} a.ovf-tile:hover{color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--accent,#3b82f6) 15%,transparent)} a.ovf-tile:active{transform:scale(.96)} .ovf-tile b{color:var(--text-primary,#f1f4fb);font-weight:800;font-variant-numeric:tabular-nums} .ovf-tile small{color:var(--text-muted,#8b93ad)} .ovf-tile .ovf-ic{font-size:1.15em;color:var(--accent-light,var(--accent,#60a5fa))} .ovf-tile.is-live{background:color-mix(in srgb,var(--live-red-text,#ef4444) 15%,transparent)} .ovf-tile.is-channel{padding:0 12px;color:var(--text-primary,#f1f4fb)} @media (pointer:coarse){.ovf-who>summary,.ovf-tile,.ovf-who-list a{min-height:44px}} .ovf-pulse a:hover{color:var(--accent,#60a5fa)} .ovf-pulse .ovf-sep{color:var(--text-muted,#8b93ad)} .ovf-pulse-live{display:inline-flex;align-items:center;gap:7px;color:var(--text-primary,#f1f4fb)!important} .ovf-dot{width:8px;height:8px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:ovfLive 1.8s ease-out infinite} @keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}} .ovf-pulse-quiet{font-weight:500!important;color:var(--text-secondary,#b6bdd2)!important} @media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}} @media (max-width:760px){.ovf,.ovf[data-variant="compact"]{margin-top:24px}} @media (max-width:560px){ .ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))} .ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))} .ovf-cols{gap:0 16px} .ovf-col--brand{margin-bottom:14px} .ovf-col:not(.ovf-col--brand){grid-column:1/-1;border-top:1px solid var(--border,rgba(255,255,255,.08))} .ovf-col:not(.ovf-col--brand):last-child{border-bottom:1px solid var(--border,rgba(255,255,255,.08))} .ovf-fold>summary{pointer-events:auto;cursor:pointer;display:flex;align-items:center;justify-content:space-between;min-height:48px} .ovf-fold>summary h3{margin:0} .ovf-fold-n{display:inline;margin-left:auto;margin-right:10px;color:var(--text-muted,#8b93ad);font-size:.82rem;font-weight:600;font-variant-numeric:tabular-nums} .ovf-fold>summary::after{content:"";width:.5em;height:.5em;margin-right:4px;border:solid var(--text-muted,#8b93ad);border-width:0 1.8px 1.8px 0;transform:translateY(-.2em) rotate(45deg);transition:transform .2s} .ovf-fold[open]>summary::after{transform:translateY(.1em) rotate(-135deg)} .ovf-fold-body{margin-top:0;padding-bottom:14px} .ovf-bar{justify-content:center;text-align:center;padding-inline:0} .ovf-legal-inline{justify-content:center} .ovf-account{flex-direction:column;align-items:flex-start} } html[data-ov-motion=off] .ovf-col a:hover,html[data-ov-motion=off] .ovf-social a:hover,html[data-ov-motion=off] .ovf-account-links a:hover{transform:none} .ovf{-webkit-tap-highlight-color:transparent} :where(.ovf) :where(a,button):active{scale:.97} :where(.ovf) :where(a,button):focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:2px;border-radius:4px} html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity:.82}`; function injectCSS() { if (typeof document === 'undefined' || document.getElementById('ovf-css')) return; const st = document.createElement('style'); st.id = 'ovf-css'; st.textContent = CSS; document.head.appendChild(st); } // ── Phones: the columns fold ─────────────────────────────── // The server and the first render write every column open (no script: everything shows). On a phone the client // folds them, except the ones the person opened themselves, which stay open through any later render. // Wider than a phone (a phone turned sideways too) every column opens again and its heading is only a heading. const _openFolds = new Set(); let _phoneQuery = null; function foldOnPhones(el) { if (!el || typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; if (!_phoneQuery) { _phoneQuery = window.matchMedia('(max-width: 560px)'); const again = () => foldOnPhones(_mounted); if (_phoneQuery.addEventListener) _phoneQuery.addEventListener('change', again); else if (_phoneQuery.addListener) _phoneQuery.addListener(again); } const phone = _phoneQuery.matches; if (!el._ovFolds) { el._ovFolds = true; // The person's own taps on a heading (toggle events are async, so the intent is read from the click). el.addEventListener('click', (e) => { const sum = e.target && e.target.closest && e.target.closest('details.ovf-fold > summary'); if (!sum) return; if (!_phoneQuery.matches) { e.preventDefault(); return; } const d = sum.parentNode, nav = d.closest('nav'), key = nav ? nav.getAttribute('aria-label') : ''; if (d.open) _openFolds.delete(key); else _openFolds.add(key); }); } el.querySelectorAll('details.ovf-fold').forEach((d) => { const nav = d.closest('nav'); d.open = !phone || _openFolds.has(nav && nav.getAttribute('aria-label')); const sum = d.querySelector('summary'); if (sum) sum.tabIndex = phone ? 0 : -1; }); } // ── Mount ────────────────────────────────────────────────── function render() { if (!_mounted) return; const variant = shownVariant(_cfg.variant); // An opened "More from OpenVibe" stays open through a re-render (a claim, the Frame's first answer). const open = !!(_mounted.querySelector && _mounted.querySelector('details.ovf-expand[open]')); _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || ''); _mounted.setAttribute('data-variant', variant); _mounted.innerHTML = buildHTML({ ..._cfg, variant, _open: open }); foldOnPhones(_mounted); if (!_mounted._ovReport) { _mounted._ovReport = true; const fresh = (e) => { const a = e.target && e.target.closest && e.target.closest('a[data-ov-report]'); if (a) a.href = reportHref(); }; _mounted.addEventListener('click', fresh); _mounted.addEventListener('auxclick', fresh); } try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ } fillAccount({ ..._cfg, variant }); if (_cfg.shipped !== false) shippedWhenNear(_mounted); watchPulse(); } // ── The pulse: the network right now ─────────────────────── // Read once the footer is in view (nobody who never scrolls there costs a request), then every minute while it is. // One read fills the strip (owner, 2026-11-10): GET /api/frame/online says who was on the network in the last // thirty minutes (members by name only when their presence lets it, guests as a count), the live channels, a few // counts that open what they count, and this site's own items. The old /api/frame/pulse line is the fallback // until the main session's design lands, and its four facts are kept here from the same read. const PULSE_URL = `${NETWORK_URL}/api/frame/pulse`; const ONLINE_URL = `${NETWORK_URL}/api/frame/online`; // A picture that cannot show (a site's image policy without openvibe.media, a deleted file) becomes its initial // (ask 345: a broken-image icon in Locker's "5 online"). One capture listener for every on the // page, no inline handler, so it works under a strict Content-Security-Policy too. if (typeof document !== 'undefined' && !document.__ovInitials) { document.__ovInitials = true; const toInitial = (img) => { if (!img || img.tagName !== 'IMG' || !img.hasAttribute('data-ov-initial') || !img.parentNode) return; const i = document.createElement('i'); i.setAttribute('aria-hidden', 'true'); i.className = 'ov-initial'; i.textContent = img.getAttribute('data-ov-initial') || '?'; img.replaceWith(i); }; document.addEventListener('error', (e) => toInitial(e.target), true); // Pictures that had already failed before this file ran. const sweep = () => document.querySelectorAll('img[data-ov-initial]').forEach((img) => { if (img.complete && !img.naturalWidth) toInitial(img); }); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', sweep, { once: true }); else sweep(); } const PULSE_MS = 60_000; let _pulse = null; let _pulseAt = 0; let _online = null; let _onlineAt = 0; let _pulseObserver = null; let _pulseTimer = null; const fmtN = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(n)); function pulseHTML(p) { const bits = []; if (p.live_now != null) { bits.push(p.live_now > 0 ? `${fmtN(p.live_now)} live now` : 'Nobody live right now · go live'); } if (p.watching > 0) bits.push(`${fmtN(p.watching)} watching`); if (p.sites > 0) bits.push(`${fmtN(p.sites)} sites, one account`); if (p.views_7d > 0) bits.push(`${fmtN(p.views_7d)} page views this week`); return bits.join(''); } /** A number from the read, or null; the read is ours but nothing on a page trusts its input. */ const onlineN = (v) => (v === null || v === undefined || !Number.isFinite(Number(v)) ? null : Math.max(0, Math.floor(Number(v)))); const onlineUrl = (u) => (/^https:\/\/[^\s"'<>]+$/i.test(String(u || '')) ? String(u) : ''); const onlineText = (v, max) => String(v == null ? '' : v).replace(/[<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, max); /** The read, cleaned: only what the strip draws, every string capped, every link https. */ function cleanOnline(o) { if (!o || typeof o !== 'object') return null; const people = (Array.isArray(o.people) ? o.people : []).slice(0, 60).map((p) => { const username = onlineText(p && p.username, 40); const avatar = onlineUrl(p && p.avatar); return username && /^[A-Za-z0-9_.-]+$/.test(username) ? { name: onlineText(p.name, 60) || username, username, avatar, site: onlineText(p.site, 40) } : null; }).filter(Boolean); const row = (r, withLine) => { const label = onlineText(r && r.label, 40), link = onlineUrl(r && r.link); if (!label || !link) return null; const value = onlineN(r.value); const out = { label, link, icon: onlineText(r.icon, 40) }; if (value != null && r.value !== undefined) out.value = value; else if (withLine) out.line = onlineText(r.line, 80); return out; }; const live = o.live && typeof o.live === 'object' ? { channels: onlineN(o.live.channels), watching: onlineN(o.live.watching), first: (Array.isArray(o.live.first) ? o.live.first : []).slice(0, 3).map((s) => ({ name: onlineText(s && s.name, 100), url: onlineUrl(s && s.url) })).filter((s) => s.name && s.url) } : null; return { members: onlineN(o.members), guests: onlineN(o.guests), total: onlineN(o.total), more: onlineN(o.more), people, live, counts: (Array.isArray(o.counts) ? o.counts : []).map((c) => row({ ...c, link: c && (c.link || c.url) }, false)).filter(Boolean), items: (Array.isArray(o.items) ? o.items : []).map((i) => row(i, true)).filter(Boolean), sites: onlineN(o.sites), views_7d: onlineN(o.views_7d), }; } /** The strip from the one read: the old facts it carries, who is online, the counts and this site's own items. */ function onlineHTML(o) { // Who is on the network, first: a count with a few faces, and (where anyone may be named) the list an old forum // had under its pages: every member by name, then how many more and how many guests. A guest is a number only. let who = ''; if (o.total > 0) { const face = (p, size) => (p.avatar ? `` : ``); const parts = [o.members > 0 ? `${fmtN(o.members)} member${o.members === 1 ? '' : 's'}` : '', o.guests > 0 ? `${fmtN(o.guests)} guest${o.guests === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '); // Shorthand (the owner, 2026-11-10: "Make it like shorthand '3 online'. If you put your mouse over it, it says // 'last 30 minutes'"): the count and the faces. When, and how many are members and guests, are the tooltip's // and the first line of what a press opens (a finger never sees a tooltip). const head = `${fmtN(o.total)} online${o.people.length ? `` : ''}`; const when = `

In the last 30 minutes${parts ? ` · ${parts}` : ''}

`; const list = o.people.map((p) => `
  • ${face(p, 26)}${esc(p.name)}${p.site ? `${esc(p.site)}` : ''}
  • `).join(''); who = `
    ${head}${when}${list ? `
      ${list}
    ` : ''}${o.more > 0 ? `

    and ${fmtN(o.more)} more

    ` : ''}
    `; } // Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this // site's own items, and the two facts the strip always had. const tile = (href, cls, inner) => (href ? `${inner}` : `${inner}`); const row = (r) => tile(r.link, '', `${icon(r.icon)}${r.value != null ? `${esc(fmtN(r.value))}` : ''}${esc(r.label)}${r.line ? `${esc(r.line)}` : ''}`); const tiles = []; const live = o.live; if (live && live.channels > 0) { tiles.push(tile('https://openvibe.live/', ' is-live', `${fmtN(live.channels)}live now${live.watching > 0 ? `${fmtN(live.watching)} watching` : ''}`)); live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `${esc(c.name)}`))); } o.counts.forEach((c) => tiles.push(row(c))); o.items.forEach((i) => tiles.push(row(i))); if (o.sites > 0) tiles.push(tile(`${NETWORK_URL}/#network`, '', `${icon('sites')}${fmtN(o.sites)}sites, one account`)); if (o.views_7d > 0) tiles.push(tile('', '', `${icon('chart')}${fmtN(o.views_7d)}page views this week`)); const more = o.counts.length || o.items.length; return `
    ${who}${tiles.length ? `
    ${tiles.join('')}
    ` : ''}
    `; } function paintPulse() { if (!_mounted) return; const html = _online ? onlineHTML(_online) : (_pulse ? pulseHTML(_pulse) : ''); for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) { el.innerHTML = html; el.hidden = !html; } } function readPulse() { if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return; if (_pulse && Date.now() - _pulseAt < PULSE_MS - 1000) { paintPulse(); return; } fetch(PULSE_URL, { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then((p) => { if (!p || typeof p !== 'object') return; const n = (v) => (Number.isFinite(Number(v)) && v !== null ? Math.max(0, Math.floor(Number(v))) : null); _pulse = { live_now: n(p.live_now), watching: n(p.watching), sites: n(p.sites), views_7d: n(p.views_7d) }; _pulseAt = Date.now(); paintPulse(); }).catch(() => { /* a quiet footer is fine */ }); } /** The strip's one read; the old pulse endpoint only when it does not answer, until the design lands. */ function readNetwork() { if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return; if (_online && Date.now() - _onlineAt < PULSE_MS - 1000) { paintPulse(); return; } // This site's own items come back only when it asks as itself (its service id, or its site id). const site = (_cfg.service || detectService() || ''); const url = site ? `${ONLINE_URL}?site=${encodeURIComponent(site)}` : ONLINE_URL; fetch(url, { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then((o) => { const clean = cleanOnline(o); if (!clean) { readPulse(); return; } _online = clean; _onlineAt = Date.now(); paintPulse(); }).catch(() => { readPulse(); }); } function watchPulse() { if (!_mounted || _cfg.pulse === false || typeof window === 'undefined') return; if (_online || _pulse) paintPulse(); if (_pulseObserver) return; if (typeof IntersectionObserver !== 'function') { readNetwork(); return; } _pulseObserver = new IntersectionObserver((entries) => { const seen = entries.some(e => e.isIntersecting); clearInterval(_pulseTimer); _pulseTimer = null; if (!seen) return; readNetwork(); _pulseTimer = setInterval(readNetwork, PULSE_MS); }, { rootMargin: '200px 0px' }); _pulseObserver.observe(_mounted); } // shipped.js mounts every [data-ov-shipped] element when it loads; loaded once, lazily, after the footer. let _shippedLoading = false; // shipped.js's content hash (scripts/sibling-revs.js writes it), and the footer's "shipped" line is fetched only when the // footer comes near the screen (4.138.0): most visits never scroll there, and it is 12 KB. const FOOTER_REV = { 'shipped.js': '6b4ed7c3dc2d' }; let _shippedSeen = null; function shippedWhenNear(el) { if (typeof IntersectionObserver !== 'function' || !el || !el.getBoundingClientRect) { loadShipped(); return; } if (_shippedSeen) return; _shippedSeen = new IntersectionObserver((es) => { if (es.some((e) => e.isIntersecting)) { _shippedSeen.disconnect(); _shippedSeen = null; loadShipped(); } }, { rootMargin: '900px 0px' }); _shippedSeen.observe(el); } function loadShipped() { if (typeof document === 'undefined') return; if (root.OpenVibeShipped && typeof root.OpenVibeShipped.scan === 'function') { try { root.OpenVibeShipped.scan(document); } catch { /* */ } return; } if (_shippedLoading || document.querySelector('script[data-ov-shipped-src]')) return; _shippedLoading = true; const sc = document.createElement('script'); let src = `${NETWORK_URL}/shared/shipped.js`; try { if (OWN_SRC) src = new URL('shipped.js', OWN_SRC).href; } catch { /* */ } sc.src = src + (FOOTER_REV['shipped.js'] ? `?v=${FOOTER_REV['shipped.js']}` : ''); sc.defer = true; sc.setAttribute('data-ov-shipped-src', ''); (document.head || document.documentElement).appendChild(sc); } function init(cfg = {}) { _cfg = { ...DEFAULTS, ..._cfg, ...cfg }; _cfg.service = _cfg.service || detectService(); injectCSS(); const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount; if (!el) return null; _mounted = el; // Started: shell.page's own start (after the site's scripts) leaves a footer the site started alone. try { el.setAttribute('data-ovf-live', ''); } catch { /* */ } render(); // 'auto' on a single-page site follows its routes: back and forward re-read the path (a route the site pushes // itself calls setVariant or render). if (_cfg.variant === 'auto' && !_routeWatch && typeof window !== 'undefined' && window.addEventListener) { _routeWatch = true; window.addEventListener('popstate', () => { if (_cfg.variant === 'auto' && _mounted) render(); }); } return el; } let _routeWatch = false; /** The page's own variant (an SPA route's); a claim still wins while it holds. */ function setVariant(variant) { if (variant !== 'full' && variant !== 'compact' && variant !== 'auto') return; if (_cfg.variant === variant) return; const before = shownVariant(_cfg.variant); _cfg.variant = variant; if (shownVariant(variant) !== before) render(); } /** * A part of the page asks for a variant while it needs it: Live's channel claims 'compact' while its stream plays and * releases it when the stream ends or the route changes, and the page's own variant comes back. Same key twice * replaces. → release() */ function claim(variant, key = 'default') { if (variant !== 'full' && variant !== 'compact') return () => {}; const k = String(key); const before = shownVariant(_cfg.variant); _claims.set(k, variant); if (shownVariant(_cfg.variant) !== before) render(); return function release() { if (_claims.get(k) !== variant) return; const was = shownVariant(_cfg.variant); _claims.delete(k); if (shownVariant(_cfg.variant) !== was) render(); }; } /** * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element. * ssr({ service, variant, links, mountId }) → '
    …
    ' */ function ssr(cfg = {}) { const c = { ...DEFAULTS, ...cfg }; if (c.variant === 'auto') c.variant = autoFor(cfg.path, c.fullOn); // the request's path, on the server const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, ''); return ``; } return { init, ssr, setVariant, claim, buildHTML, render, CSS, NETWORK, LEGAL, detectService, pulseHTML, onlineHTML, cleanOnline, autoFor, get config() { return { ..._cfg }; }, get variant() { return shownVariant(_cfg.variant); } }; });