openvibers/openvibe.shared footer.js

footer.js

main 63.4 KB · 840 lines · javascript

1/**
2 * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js.
3 *
4 * OpenVibeFooter.init({ service, variant, links, apiBase, mount, pulse })
5 * OpenVibeFooter.setVariant('full' | 'compact' | 'auto') the page's own variant (SPA route changes)
6 * OpenVibeFooter.claim('compact' | 'full', key) → release(): a part of the page asks for a variant while it needs
7 * it (Live's channel while its stream is live: compact; offline:
8 * the page's own full). Compact wins over full, any claim over the
9 * page's own; releasing a claim brings back what is left.
10 * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR
11 *
12 * Why this exists: every property was hand-rolling its own footer, so the network links, the
13 * legal links and the styling drifted apart, and a site that gained a new sibling had to be
14 * edited by hand. Here the site passes only the links that are *its own*; the Network column,
15 * the Legal column, the signed-in row and all the styling come from this module, so adding a
16 * property or changing a policy link updates every site at once.
17 *
18 * Variants:
19 * auto full on the site's front page (and the paths in fullOn), compact everywhere else: a site looks like an app
20 * once someone is in it (the owner, 2026-10-28: "the full-size footer on the chat pages might be a little
21 * much … if we want to have that app-like design … make it a little more sleeker across sites"). On the
22 * server, ssr({ variant: 'auto', path }) picks with the request's path (no path: compact).
23 * full the multi-column footer, for home and browse pages
24 * compact one quiet line, for pages where the footer is not the point — a streamer's
25 * channel, a player, a tool someone is mid-task in. "More from OpenVibe" opens the
26 * whole footer in place (a <details>: no script needed, and every link stays in the
27 * page for crawlers, so a player page links the network as a home page does)
28 *
29 * The pulse line (full, and an opened compact one): what is happening on the network right now —
30 * streams live, people watching, open sites, last week's views — from openvibe.network/api/frame/pulse,
31 * read once the footer scrolls into view and every minute while it stays there; numbers only.
32 *
33 * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
34 * hub, so the network behaves like one product instead of a ring of separate sites. It reads
35 * the shared ov_token the same way navbar.js does and never blocks rendering on it.
36 *
37 * The page's end (plan 280, the owner: "That huge wasted space … in between that and the footer … It's just waste"):
38 * content ends, then 32 px on a computer (24 on a phone), then the footer — the footer's own margin-top is that
39 * number and nothing else may add to it. A site's bottom padding above the footer is its own dead space and is
40 * removed in that site (scripts/audit-page-end.js measures it on every home).
41 */
42(function (root, factory) {
43 const api = factory();
44 if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR)
45 if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser
46})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
47 'use strict';
48
49 const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});
50 // Where this file was served from: shipped.js is loaded from the same place (the site's own
51 // /shared/ copy, or openvibe.network), so the site's CSP already allows it.
52 const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })();
53 // ── Frame data (https://openvibe.network/api/frame) ─────────────────────────────
54 // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
55 // minutes in localStorage and refreshed in the background, so pages paint from cache and the
56 // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
57 const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () {
58 const KEY = 'ov_frame_v2', TTL = 30 * 60000; // v2: every site, with its launcher group
59 let inflight = null;
60 const host = () => (typeof location !== 'undefined' ? location.hostname : '');
61 const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
62 function clean(d) {
63 if (!d || !Array.isArray(d.nav)) return null;
64 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);
65 const f = d.footer || {}, lg = f.legal || {};
66 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),
67 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),
68 legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
69 }
70 function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
71 function refresh() {
72 if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call out
73 if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
74 inflight = fetch('https://openvibe.network/api/frame?host=' + encodeURIComponent(host()), { credentials: 'omit' })
75 .then(r => (r.ok ? r.json() : null)).then(clean)
76 .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
77 .catch(() => null);
78 return inflight;
79 }
80 /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
81 function get(onFirst) {
82 const c = cached();
83 if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
84 return c ? c.data : null;
85 }
86 return { get, refresh };
87 })());
88
89 const NETWORK_URL = 'https://openvibe.network';
90 const REPORT_URL = 'https://openvibe.help/tickets/new';
91 const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
92
93 // The eight groups, in the menu's order (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of
94 // 2026-11-10). The Network column links each group's section on /sites; the Frame's `groups` replaces these names
95 // and order when it answers, so the footer, the launcher and the home pages read the same list.
96 const GROUP_LINKS = [
97 { id: 'watch', name: 'Watch & stream' },
98 { id: 'talk', name: 'Talk & belong' },
99 { id: 'play', name: 'Play' },
100 { id: 'read', name: 'Read & ask' },
101 { id: 'shop', name: 'Shop & money' },
102 { id: 'tools', name: 'Free tools' },
103 { id: 'make', name: 'Make with AI' },
104 { id: 'build', name: 'For developers' },
105 ];
106 const groupHref = (id) => `${NETWORK_URL}/sites#group-${encodeURIComponent(id)}`;
107 const NETWORK = GROUP_LINKS.map(({ id, name }) => ({ id, name, url: groupHref(id), desc: '' }));
108 // Tool subdomains have no site of their own; brandFor names them "<Sub>.OpenVibe.Tools".
109 const TOOL_FAMILIES = new Set(['dev', 'net', 'img', 'audio', 'docs', 'text', 'yt']);
110
111 const LEGAL = [
112 { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
113 { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
114 { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
115 ];
116
117 // "Report a problem" on every site: a ticket on openvibe.help with this site and page filled in (OpenVibe.Help#81).
118 // The page is the origin and path only, since a query or fragment can carry a sign-in code or a token. Rendered on
119 // the server the page is unknown, so a click fills it in at that moment (a single-page app's route counts).
120 function reportHref() {
121 try {
122 if (typeof location === 'undefined' || !/^https?:$/.test(location.protocol)) return REPORT_URL;
123 return `${REPORT_URL}?${new URLSearchParams({ site: location.hostname.replace(/^www\./, ''), page: (location.origin + location.pathname).slice(0, 500) })}`;
124 } catch { return REPORT_URL; }
125 }
126 const reportLink = () => `<a href="${esc(reportHref())}" data-ov-report>Report a problem</a>`;
127
128 const SOCIAL = [
129 { name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' },
130 { name: 'Discord', icon: 'discord', url: 'https://discord.gg/M6MuRUaeJj' },
131 ];
132
133 const DEFAULTS = {
134 service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
135 brandName: null,
136 tagline: 'Open source and community run. Built in the open by the people using it.',
137 variant: 'full', // 'full' | 'compact' | 'auto'
138 fullOn: ['/'], // with variant 'auto': the paths that get the full footer
139 mount: '#ov-footer',
140 links: [], // [{ heading, items: [{ name, url, onclick? }] }]
141 legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site
142 apiBase: NETWORK_URL,
143 user: null,
144 showNetwork: true,
145 showAccount: true,
146 sitemap: '/sitemap.xml',
147 shipped: true, // the "shipped X ago" line (openvibe-shared/shipped.js)
148 pulse: true, // the network-right-now line (openvibe.network/api/frame/pulse)
149 updates: null, // this site's update log; default: openvibe.network/updates?site=<host>
150 };
151
152 let _cfg = { ...DEFAULTS };
153 let _mounted = null;
154 const _claims = new Map(); // key → 'compact' | 'full' (claim())
155 /** 'auto' for a path: full on the front page and the paths in fullOn, compact elsewhere (no path: compact). */
156 function autoFor(path, fullOn) {
157 if (typeof path !== 'string' || !path) return 'compact';
158 const p = path.replace(/[?#].*$/, '').replace(/(.)\/+$/, '$1') || '/';
159 return (Array.isArray(fullOn) && fullOn.length ? fullOn : ['/']).includes(p) ? 'full' : 'compact';
160 }
161 const herePath = () => (typeof location !== 'undefined' && location.pathname) || '/';
162 /** The variant shown: a compact claim, else a full one, else the page's own ('auto' read from where the page is). */
163 function shownVariant(base) {
164 const asked = [..._claims.values()];
165 if (asked.includes('compact')) return 'compact';
166 if (asked.includes('full')) return 'full';
167 if (base === 'auto') return autoFor(herePath(), _cfg.fullOn);
168 return base === 'compact' ? 'compact' : 'full';
169 }
170
171 function detectService() {
172 if (typeof location === 'undefined') return 'network';
173 const h = location.hostname;
174 if (h.endsWith('openvibe.live')) return 'live';
175 if (h.endsWith('openvibe.network')) return 'network';
176 if (h.endsWith('openvibe.games')) return 'games';
177 if (/(^|\.)openre\.stream$/.test(h)) return 'openre';
178 const tld = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);
179 if (tld && tld[1] !== 'tools' && TLD_LABELS[tld[1]]) return tld[1];
180 const sub = h.replace(/\.openvibe\.tools$/, '');
181 return sub === h ? 'tools' : (sub || 'tools');
182 }
183
184 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' };
185 /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */
186 function brandFor(service) {
187 // The navbar already resolved the spelling (MergePDF, JSON, Pastes…): reuse it so the two
188 // never disagree on the same page.
189 try { const nb = typeof window !== 'undefined' && window.OpenVibeNavbar && window.OpenVibeNavbar.brand && window.OpenVibeNavbar.brand(); if (nb && nb.name) return nb.name; } catch { /* */ }
190 const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';
191 const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
192 let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
193 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('.'); }
194 if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRestream';
195 if ((m = h.match(/^(?:(.+)\.)?open\.([a-z]+)$/))) return `Open.${TLD_LABELS[m[2]] || cap(m[2])}`;
196 if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
197 if (TOOL_FAMILIES.has(service)) return `${cap(service)}.OpenVibe.Tools`;
198 return 'OpenVibe';
199 }
200
201 function legalHref(item, cfg) {
202 // Each site answers for itself: the Frame service names this domain's own documents.
203 const frame = cfg._frame;
204 if (frame && frame.footer.legal && !cfg.legalBase) return frame.footer.legal[item.key];
205 // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
206 if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
207 if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
208 return item.fallback;
209 }
210
211 // ── Icons ──────────────────────────────────────────────────
212 // The footer ships its own icons. It used to borrow the page's Font Awesome, so on pages that do not
213 // load that font (the server-rendered Tools and legal pages) every icon was an empty box.
214 const ICONS = {
215 github: '<path fill="currentColor" stroke="none" d="M12 2.2a9.8 9.8 0 0 0-3.1 19.1c.5.1.7-.2.7-.5v-1.7c-2.7.6-3.3-1.2-3.3-1.2-.4-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.2-.2-4.5-1.1-4.5-4.8 0-1.1.4-1.9 1-2.6-.1-.2-.4-1.2.1-2.6 0 0 .8-.3 2.7 1a9.3 9.3 0 0 1 4.9 0c1.9-1.3 2.7-1 2.7-1 .5 1.4.2 2.4.1 2.6.6.7 1 1.5 1 2.6 0 3.7-2.3 4.6-4.5 4.8.4.3.7.9.7 1.800v2.700c0 .3.2.6.7.5A9.8 9.8 0 0 0 12 2.200z"/>',
216 discord: '<path fill="currentColor" stroke="none" d="M19.3 5.300a16.5 16.5 0 0 0-4.1-1.300l-.5 1a15.3 15.3 0 0 0-5.4 0l-.5-1a16.5 16.5 0 0 0-4.1 1.300C2.1 9.2 1.4 13 1.7 16.700a16.6 16.6 0 0 0 5.1 2.600l1.1-1.800c-.6-.2-1.2-.5-1.7-.8l.4-.3a11.8 11.8 0 0 0 10.8 0l.4.3c-.5.3-1.1.6-1.7.8l1.1 1.800a16.6 16.6 0 0 0 5.1-2.600c.4-4.3-.7-8.1-3-11.400zM8.7 14.400c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100zm6.6 0c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100z"/>',
217 sitemap: '<rect x="9" y="3.5" width="6" height="4.5" rx="1"/><rect x="3" y="16" width="6" height="4.5" rx="1"/><rect x="15" y="16" width="6" height="4.5" rx="1"/><path d="M12 8v4M6 16v-2.500h12V16"/>',
218 arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
219 channel: '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.800a6 6 0 0 0 0 8.400M16.2 7.800a6 6 0 0 1 0 8.400M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',
220 dashboard: '<path d="M4.5 17a8 8 0 1 1 15 0M12 13l3.5-4"/><circle cx="12" cy="13.5" r="1.3"/>',
221 account: '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>',
222 themes: '<path d="M12 4.500a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.300h1.600a3.2 3.2 0 0 0 3.2-3.200C19.5 8 16.2 4.5 12 4.500z"/><circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',
223 tools: '<path d="M14.5 5.500a4 4 0 0 0-5.2 5.200L4.5 15.500a1.8 1.8 0 0 0 2.5 2.500l4.8-4.800a4 4 0 0 0 5.2-5.200l-2.6 2.6-2.1-.5-.5-2.100z"/>',
224 // 4.188.0: the strip's icons, drawn by openvibe-shared/glyphs.js (the same hand) for the counts and the
225 // items a site declares. A name the set does not carry falls back to the arrow above.
226 members: '<circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 4.8a3.2 3.2 0 0 1 0 6.4M18 14.8c1.9.6 3 2.4 3 5.2"/>',
227 video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>',
228 clipboard: '<rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V3.5h6V5M9 11h6M9 15h4"/>',
229 image: '<rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/>',
230 news: '<path d="M4 5h13v14a2 2 0 0 0 2 2H6a2 2 0 0 1-2-2Z"/><path d="M17 9h3v10a2 2 0 0 1-2 2M7.5 9h6M7.5 12.5h6M7.5 16h3.5"/>',
231 clapper: '<path d="M3.5 10h17v9a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1Z"/><path d="M3.5 10 2.9 6.6l16.4-3 .7 3.6ZM8 5.7l2.3 3.3M13.3 4.7l2.3 3.3"/>',
232 film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 4v16M16.5 4v16M3 9h4.5M3 15h4.5M16.5 9H21M16.5 15H21"/>',
233 game: '<rect x="2.5" y="7" width="19" height="11" rx="5"/><path d="M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01"/>',
234 live: '<circle cx="12" cy="12" r="2.5"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',
235 chart: '<path d="M4 4v16h16"/><path d="m7.5 14.5 3.5-4 3 2.5 5-6"/>',
236 star: '<path d="m12 4 2.4 5 5.4.6-4 3.7 1.1 5.4L12 16l-4.9 2.7 1.1-5.4-4-3.7 5.4-.6Z"/>',
237 sites: '<rect x="4" y="4" width="6.5" height="6.5" rx="1.7"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.7"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.7"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.7"/>',
238 };
239 const icon = (name) => `<svg class="ovf-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.800" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${(ICONS[name] || ICONS.arrow)}</svg>`;
240
241 // ── Markup ─────────────────────────────────────────────────
242 function linkTag(raw) {
243 // Items are { name, url }; { label, href } (the navbar's shape) is read the same, so a site that
244 // passes it does not get empty links.
245 const item = { ...raw, name: raw.name ?? raw.label, url: raw.url ?? raw.href };
246 // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.
247 const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);
248 const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : '';
249 const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
250 const title = item.desc ? ` title="${esc(item.desc)}"` : '';
251 return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
252 }
253
254 /** The "🚀 shipped X ago: …" line, filled by shipped.js from the network changelog; hidden until then. */
255 function shippedLine(c) {
256 if (c.shipped === false) return '';
257 const host = typeof location !== 'undefined' ? location.hostname : '';
258 const updates = c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;
259 return `<a class="ovf-shipped" data-ov-shipped="latest" data-service="${esc(c.service || 'auto')}" href="${esc(updates)}" hidden></a>`;
260 }
261 function updatesHref(c) {
262 const host = typeof location !== 'undefined' ? location.hostname : '';
263 return c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;
264 }
265
266 function buildHTML(cfg) {
267 const c = { ...DEFAULTS, ...(cfg || {}) };
268 const service = c.service || detectService();
269 const brand = c.brandName || brandFor(service);
270 const frame = c._frame = OVFrame.get(() => { try { render(); } catch { /* */ } });
271 // The Frame's own groups, in its order, when it answers; the built-in eight otherwise. One column of groups,
272 // not a head-list of sites, so the footer shows the same neighbourhoods as the launcher and the home pages.
273 const network = frame && Array.isArray(frame.groups) && frame.groups.length
274 ? frame.groups.map(g => ({ name: g.name, url: groupHref(g.id), desc: '' }))
275 : NETWORK;
276 // The ranked sites are still what the discover picks must not repeat.
277 const nav = frame && frame.nav.length
278 ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname))
279 : [];
280 const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline;
281 const popular = frame ? frame.footer.popular : [];
282 const discover = frame ? frame.footer.discover.filter(d => !nav.slice(0, 8).some(n => n.url === d.url)) : [];
283 const year = new Date().getFullYear();
284
285 if (c.variant === 'compact') {
286 return `<div class="ovf-inner ovf-inner--compact">
287 <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="28" data-wide></span><span>${esc(brand)}</span></a>
288 <nav class="ovf-compact-links" aria-label="Site links">
289 ${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}
290 <a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
291 ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}
292 ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
293 ${reportLink()}
294 </nav>
295 ${shippedLine(c)}
296 <span class="ovf-compact-meta">Open source · ${year}</span>
297 </div>
298 <details class="ovf-expand"${c._open ? ' open' : ''}><summary>More from OpenVibe</summary>${fullBody(c, { network, popular, discover, brand, tagline, compact: true })}</details>`;
299 }
300 return fullBody(c, { network, popular, discover, brand, tagline });
301 }
302
303 /** The multi-column footer: the brand, the site's own links, the network, popular tools, legal, the account row and the bar. */
304 function fullBody(c, { network, popular, discover, brand, tagline, compact = false }) {
305 // Each link column is a fold: open on a desktop (its heading reads and behaves as a heading), folded on a phone
306 // (a 48 px row with a chevron), so a phone's footer is about one screen shorter. Every link stays in the page.
307 // The fold's line carries its count ("9 links"), so a folded column still says how much it holds (#208).
308 const fold = (label, heading, body, count) => `
309 <nav class="ovf-col" aria-label="${esc(label)}"><details class="ovf-fold" open><summary><h3>${esc(heading)}</h3><span class="ovf-fold-n">${esc(String(count))} ${count === 1 ? 'link' : 'links'}</span></summary>
310 <div class="ovf-fold-body">${body}</div></details></nav>`;
311 // A column needs a name and at least one link: an untitled "0 links" fold is never drawn (4.202.0's screenshots
312 // showed two when a site passed empty groups).
313 const cols = (c.links || []).filter(group => group && String(group.heading || '').trim() && Array.isArray(group.items) && group.items.length)
314 .map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''), group.items.length)).join('');
315 const networkLinks = network.slice(0, 8);
316
317 return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">
318 ${c.pulse === false ? '' : '<div class="ovf-pulse" data-ovf-pulse hidden></div>'}
319 <div class="ovf-cols">
320 <div class="ovf-col ovf-col--brand">
321 <a class="ovf-brand" href="/"><span class="ov-mark" data-size="38" data-wide></span><span>${esc(brand)}</span></a>
322 <p class="ovf-tagline">${esc(tagline)}</p>
323 <div class="ovf-social">
324 ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}">${icon(s.icon)}</a>`).join('')}
325 <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap">${icon('sitemap')}</a>
326 </div>
327 </div>
328 ${cols}
329 ${c.showNetwork ? fold('The OpenVibe network', 'Network', `${networkLinks.map(linkTag).join('')}
330 <a class="ovf-more" href="${NETWORK_URL}/sites" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`, networkLinks.length + 1) : ''}
331 ${popular.length ? fold('Popular tools', 'Popular tools', `${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
332 <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>`, Math.min(popular.length, 6) + Math.min(discover.length, 2) + 1) : ''}
333 ${fold('Legal', 'Legal', `${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
334 ${reportLink()}`, LEGAL.length + 1)}
335 </div>
336 ${compact ? '' : `<div class="ovf-account" id="ovf-account" hidden></div>
337 <div class="ovf-bar">
338 <span class="ovf-copy">Open source &amp; community driven · built in the open</span>
339 ${shippedLine(c)}
340 <span class="ovf-legal-inline">
341 ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}
342 ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
343 </span>
344 </div>`}
345 </div>`;
346 }
347
348 // ── Signed-in row: the same account, wherever you are ──────
349 function accountHTML(user, service) {
350 const name = esc(user.display_name || user.username || 'you');
351 // An openvibe.media object picture comes in sizes (Media's /o/:id?w=&fmt=): 64 px as WebP for the 24 px avatar.
352 // Without one in the record, the Network's /avatar/<username>: the picture the account has now (navbar.js, the same rule).
353 const media = /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]{10,40}$/.test(user.avatar_url || '');
354 const sized = media ? `${user.avatar_url}?w=64&fmt=webp` : (user.username && !user.is_anon ? `${NETWORK_URL}/avatar/${encodeURIComponent(user.username)}?s=32` : '');
355 const avatar = sized
356 ? `<img src="${esc(sized)}" alt="" width="${media ? 64 : 32}" height="${media ? 64 : 32}" loading="lazy" decoding="async" data-ov-initial="${esc(String(name).charAt(0).toUpperCase())}">`
357 : `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
358 const quick = [
359 service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' },
360 { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'account' },
361 { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' },
362 { name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' },
363 ];
364 return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>
365 <span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}>${icon(q.icon)} ${esc(q.name)}</a>`).join('')}</span>`;
366 }
367
368 function storedToken() {
369 try {
370 const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
371 return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null;
372 } catch { return null; }
373 }
374
375 async function fillAccount(cfg) {
376 const host = document.getElementById('ovf-account');
377 if (!host || !cfg.showAccount || cfg.variant === 'compact') return;
378 let user = cfg.user;
379 if (!user) {
380 const token = storedToken();
381 if (!token) return;
382 try {
383 const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' });
384 if (!res.ok) return;
385 const data = await res.json();
386 user = data.user || data;
387 } catch { return; }
388 }
389 if (!user || !user.username) return;
390 host.innerHTML = accountHTML(user, cfg.service || detectService());
391 host.hidden = false;
392 }
393
394 // ── Styles (injected once) ─────────────────────────────────
395 const CSS = `
396.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);
397 padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}
398.ovf[data-variant="compact"]{margin-top:32px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
399.ovf-inner{max-width:1240px;margin:0 auto}
400.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}
401.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}
402.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}
403.ovf-col h3{margin:0 0 2px;font-size:.75rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}
404.ovf-fold>summary{list-style:none;cursor:default;pointer-events:none}
405.ovf-fold>summary::-webkit-details-marker{display:none}
406.ovf-fold-body{display:flex;flex-direction:column;gap:8px;margin-top:8px}
407.ovf-fold-n{display:none}
408.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}
409.ovf-col a:hover{color:var(--accent,#60a5fa);transform:translateX(2px)}
410@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}}
411.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}
412.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}
413.ovf-brand--sm{font-size:.9rem}
414.ovf-brand::after{content:'';position:absolute;inset:-6px 0}
415.ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch}
416.ovf-social{display:flex;gap:8px;margin-top:4px}
417.ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2);
418 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}
419.ovf-social a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-2px)}
420.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;
421 margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;
422 background:color-mix(in srgb, var(--accent,#60a5fa) 8%, var(--bg-tertiary,#1a1b28));
423 border:1px solid color-mix(in srgb, var(--accent,#60a5fa) 26%, var(--border,rgba(255,255,255,.08)));
424 animation:ovfIn .4s ease-out both}
425@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
426.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}
427.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}
428.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;
429 background:var(--accent-strong,var(--accent,#3472d8));color:var(--on-accent-strong,var(--on-accent,#fff));font-size:.7rem;font-weight:800;flex:none}
430.ovf-avatar img{width:100%;height:100%;object-fit:cover}
431.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}
432.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;
433 color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));
434 text-decoration:none;transition:color .15s,border-color .15s,transform .15s}
435.ovf-account-links a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-1px)}
436.ovf-account-links i{font-size:.72rem;opacity:.8}
437.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;
438 margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));
439 padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}
440.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}
441.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}
442.ovf-compact-links{justify-content:center}
443.ovf [hidden],.ovf-account[hidden]{display:none!important}
444.ovf .ovf-shipped.ov-shipped-latest{font-size:.76rem;padding:4px 12px;max-width:min(520px,100%);background:transparent}
445@media (pointer:coarse){.ovf .ovf-shipped.ov-shipped-latest{min-height:44px;padding-top:12px;padding-bottom:12px;box-sizing:border-box}}
446.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}
447.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)}
448.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.75rem}
449.ovf-expand{max-width:1240px;margin:8px auto 0}
450.ovf[data-variant="compact"]{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;column-gap:18px}
451.ovf[data-variant="compact"]>.ovf-inner--compact{flex:0 1 auto;min-width:0;margin:0}
452.ovf[data-variant="compact"]>.ovf-expand{margin:0}
453.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}
454.ovf[data-variant="compact"]>.ovf-expand[open]{flex:1 1 100%;margin:10px auto 0}
455.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}
456.ovf-expand>summary::-webkit-details-marker{display:none}
457.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}
458.ovf-expand[open]>summary::after{transform:translateY(.05em) rotate(-135deg)}
459.ovf-expand>summary:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa)}
460.ovf-expand[open]>summary{margin-bottom:clamp(16px,2.5vw,24px)}
461.ovf-inner--expanded{animation:ovfIn .3s ease-out both}
462.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);
463 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}
464.ovf-pulse a{color:inherit;text-decoration:none;font-weight:600}
465.ovf-now{flex:1 1 100%;min-width:0;display:grid;gap:8px}
466.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)}
467.ovf-who>summary{cursor:pointer;border-radius:10px}
468.ovf-who>summary::-webkit-details-marker{display:none}
469.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}
470.ovf-who[open]>summary::after{transform:translateY(3px) rotate(-135deg)}
471.ovf-who>summary:focus-visible{outline:2px solid var(--accent,#60a5fa);outline-offset:3px}
472.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}
473@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)}}
474.ovf-who-n{font-weight:500}.ovf-who-n b{font-size:1.05em;font-weight:800;font-variant-numeric:tabular-nums}
475.ovf-who-when{margin:6px 2px 0;color:var(--text-muted,#8b93ad)}
476.ovf-who-faces{display:inline-flex;padding-left:7px}
477.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}
478.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))}
479.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}
480@keyframes ovf-list{from{opacity:0;transform:translateY(calc(-6px*var(--ov-move,1)))}}
481.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}
482.ovf-who-list a:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--text-primary,#f1f4fb)}
483.ovf-who-list a:active{transform:scale(.96)}
484.ovf-who-list img,.ovf-who-list i{flex:none;width:26px;height:26px;border-radius:50%;object-fit:cover}
485.ovf-who-list small{color:var(--text-muted,#8b93ad);font-weight:500}
486.ovf-who-more{margin:8px 2px 0;color:var(--text-muted,#8b93ad)}
487.ovf-tiles{display:flex;gap:6px;margin:0 -14px;padding:0 14px 2px;overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:none}
488.ovf-tiles::-webkit-scrollbar{display:none}
489.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}
490a.ovf-tile:hover{color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--accent,#3b82f6) 15%,transparent)}
491a.ovf-tile:active{transform:scale(.96)}
492.ovf-tile b{color:var(--text-primary,#f1f4fb);font-weight:800;font-variant-numeric:tabular-nums}
493.ovf-tile small{color:var(--text-muted,#8b93ad)}
494.ovf-tile .ovf-ic{font-size:1.15em;color:var(--accent-light,var(--accent,#60a5fa))}
495.ovf-tile.is-live{background:color-mix(in srgb,var(--live-red-text,#ef4444) 15%,transparent)}
496.ovf-tile.is-channel{padding:0 12px;color:var(--text-primary,#f1f4fb)}
497@media (pointer:coarse){.ovf-who>summary,.ovf-tile,.ovf-who-list a{min-height:44px}}
498.ovf-pulse a:hover{color:var(--accent,#60a5fa)}
499.ovf-pulse .ovf-sep{color:var(--text-muted,#8b93ad)}
500.ovf-pulse-live{display:inline-flex;align-items:center;gap:7px;color:var(--text-primary,#f1f4fb)!important}
501.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}
502@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)}}
503.ovf-pulse-quiet{font-weight:500!important;color:var(--text-secondary,#b6bdd2)!important}
504@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
505@media (max-width:760px){.ovf,.ovf[data-variant="compact"]{margin-top:24px}}
506@media (max-width:560px){
507 .ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
508 .ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
509 .ovf-cols{gap:0 16px}
510 .ovf-col--brand{margin-bottom:14px}
511 .ovf-col:not(.ovf-col--brand){grid-column:1/-1;border-top:1px solid var(--border,rgba(255,255,255,.08))}
512 .ovf-col:not(.ovf-col--brand):last-child{border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
513 .ovf-fold>summary{pointer-events:auto;cursor:pointer;display:flex;align-items:center;justify-content:space-between;min-height:48px}
514 .ovf-fold>summary h3{margin:0}
515 .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}
516 .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}
517 .ovf-fold[open]>summary::after{transform:translateY(.1em) rotate(-135deg)}
518 .ovf-fold-body{margin-top:0;padding-bottom:14px}
519 .ovf-bar{justify-content:center;text-align:center;padding-inline:0}
520 .ovf-legal-inline{justify-content:center}
521 .ovf-account{flex-direction:column;align-items:flex-start}
522}
523html[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}
524.ovf{-webkit-tap-highlight-color:transparent}
525:where(.ovf) :where(a,button):active{scale:.97}
526:where(.ovf) :where(a,button):focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:2px;border-radius:4px}
527html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity:.82}`;
528
529 function injectCSS() {
530 if (typeof document === 'undefined' || document.getElementById('ovf-css')) return;
531 const st = document.createElement('style');
532 st.id = 'ovf-css';
533 st.textContent = CSS;
534 document.head.appendChild(st);
535 }
536
537 // ── Phones: the columns fold ───────────────────────────────
538 // The server and the first render write every column open (no script: everything shows). On a phone the client
539 // folds them, except the ones the person opened themselves, which stay open through any later render.
540 // Wider than a phone (a phone turned sideways too) every column opens again and its heading is only a heading.
541 const _openFolds = new Set();
542 let _phoneQuery = null;
543 function foldOnPhones(el) {
544 if (!el || typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
545 if (!_phoneQuery) {
546 _phoneQuery = window.matchMedia('(max-width: 560px)');
547 const again = () => foldOnPhones(_mounted);
548 if (_phoneQuery.addEventListener) _phoneQuery.addEventListener('change', again); else if (_phoneQuery.addListener) _phoneQuery.addListener(again);
549 }
550 const phone = _phoneQuery.matches;
551 if (!el._ovFolds) {
552 el._ovFolds = true;
553 // The person's own taps on a heading (toggle events are async, so the intent is read from the click).
554 el.addEventListener('click', (e) => {
555 const sum = e.target && e.target.closest && e.target.closest('details.ovf-fold > summary');
556 if (!sum) return;
557 if (!_phoneQuery.matches) { e.preventDefault(); return; }
558 const d = sum.parentNode, nav = d.closest('nav'), key = nav ? nav.getAttribute('aria-label') : '';
559 if (d.open) _openFolds.delete(key); else _openFolds.add(key);
560 });
561 }
562 el.querySelectorAll('details.ovf-fold').forEach((d) => {
563 const nav = d.closest('nav');
564 d.open = !phone || _openFolds.has(nav && nav.getAttribute('aria-label'));
565 const sum = d.querySelector('summary');
566 if (sum) sum.tabIndex = phone ? 0 : -1;
567 });
568 }
569
570 // ── Mount ──────────────────────────────────────────────────
571 function render() {
572 if (!_mounted) return;
573 const variant = shownVariant(_cfg.variant);
574 // An opened "More from OpenVibe" stays open through a re-render (a claim, the Frame's first answer).
575 const open = !!(_mounted.querySelector && _mounted.querySelector('details.ovf-expand[open]'));
576 _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
577 _mounted.setAttribute('data-variant', variant);
578 _mounted.innerHTML = buildHTML({ ..._cfg, variant, _open: open });
579 foldOnPhones(_mounted);
580 if (!_mounted._ovReport) {
581 _mounted._ovReport = true;
582 const fresh = (e) => { const a = e.target && e.target.closest && e.target.closest('a[data-ov-report]'); if (a) a.href = reportHref(); };
583 _mounted.addEventListener('click', fresh);
584 _mounted.addEventListener('auxclick', fresh);
585 }
586 try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
587 fillAccount({ ..._cfg, variant });
588 if (_cfg.shipped !== false) shippedWhenNear(_mounted);
589 watchPulse();
590 }
591
592 // ── The pulse: the network right now ───────────────────────
593 // Read once the footer is in view (nobody who never scrolls there costs a request), then every minute while it is.
594 // One read fills the strip (owner, 2026-11-10): GET /api/frame/online says who was on the network in the last
595 // thirty minutes (members by name only when their presence lets it, guests as a count), the live channels, a few
596 // counts that open what they count, and this site's own items. The old /api/frame/pulse line is the fallback
597 // until the main session's design lands, and its four facts are kept here from the same read.
598 const PULSE_URL = `${NETWORK_URL}/api/frame/pulse`;
599 const ONLINE_URL = `${NETWORK_URL}/api/frame/online`;
600 // A picture that cannot show (a site's image policy without openvibe.media, a deleted file) becomes its initial
601 // (ask 345: a broken-image icon in Locker's "5 online"). One capture listener for every <img data-ov-initial> on the
602 // page, no inline handler, so it works under a strict Content-Security-Policy too.
603 if (typeof document !== 'undefined' && !document.__ovInitials) {
604 document.__ovInitials = true;
605 const toInitial = (img) => {
606 if (!img || img.tagName !== 'IMG' || !img.hasAttribute('data-ov-initial') || !img.parentNode) return;
607 const i = document.createElement('i');
608 i.setAttribute('aria-hidden', 'true');
609 i.className = 'ov-initial';
610 i.textContent = img.getAttribute('data-ov-initial') || '?';
611 img.replaceWith(i);
612 };
613 document.addEventListener('error', (e) => toInitial(e.target), true);
614 // Pictures that had already failed before this file ran.
615 const sweep = () => document.querySelectorAll('img[data-ov-initial]').forEach((img) => { if (img.complete && !img.naturalWidth) toInitial(img); });
616 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', sweep, { once: true }); else sweep();
617 }
618 const PULSE_MS = 60_000;
619 let _pulse = null;
620 let _pulseAt = 0;
621 let _online = null;
622 let _onlineAt = 0;
623 let _pulseObserver = null;
624 let _pulseTimer = null;
625 const fmtN = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(n));
626 function pulseHTML(p) {
627 const bits = [];
628 if (p.live_now != null) {
629 bits.push(p.live_now > 0
630 ? `<a class="ovf-pulse-live" href="https://openvibe.live/"><span class="ovf-dot" aria-hidden="true"></span>${fmtN(p.live_now)} live now</a>`
631 : '<a class="ovf-pulse-quiet" href="https://openvibe.live/">Nobody live right now · go live</a>');
632 }
633 if (p.watching > 0) bits.push(`<span>${fmtN(p.watching)} watching</span>`);
634 if (p.sites > 0) bits.push(`<a href="${NETWORK_URL}/#network">${fmtN(p.sites)} sites, one account</a>`);
635 if (p.views_7d > 0) bits.push(`<span>${fmtN(p.views_7d)} page views this week</span>`);
636 return bits.join('<span class="ovf-sep" aria-hidden="true">·</span>');
637 }
638 /** A number from the read, or null; the read is ours but nothing on a page trusts its input. */
639 const onlineN = (v) => (v === null || v === undefined || !Number.isFinite(Number(v)) ? null : Math.max(0, Math.floor(Number(v))));
640 const onlineUrl = (u) => (/^https:\/\/[^\s"'<>]+$/i.test(String(u || '')) ? String(u) : '');
641 const onlineText = (v, max) => String(v == null ? '' : v).replace(/[<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, max);
642 /** The read, cleaned: only what the strip draws, every string capped, every link https. */
643 function cleanOnline(o) {
644 if (!o || typeof o !== 'object') return null;
645 const people = (Array.isArray(o.people) ? o.people : []).slice(0, 60).map((p) => {
646 const username = onlineText(p && p.username, 40);
647 const avatar = onlineUrl(p && p.avatar);
648 return username && /^[A-Za-z0-9_.-]+$/.test(username)
649 ? { name: onlineText(p.name, 60) || username, username, avatar, site: onlineText(p.site, 40) }
650 : null;
651 }).filter(Boolean);
652 const row = (r, withLine) => {
653 const label = onlineText(r && r.label, 40), link = onlineUrl(r && r.link);
654 if (!label || !link) return null;
655 const value = onlineN(r.value);
656 const out = { label, link, icon: onlineText(r.icon, 40) };
657 if (value != null && r.value !== undefined) out.value = value;
658 else if (withLine) out.line = onlineText(r.line, 80);
659 return out;
660 };
661 const live = o.live && typeof o.live === 'object'
662 ? { 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) }
663 : null;
664 return {
665 members: onlineN(o.members), guests: onlineN(o.guests), total: onlineN(o.total), more: onlineN(o.more),
666 people, live,
667 counts: (Array.isArray(o.counts) ? o.counts : []).map((c) => row({ ...c, link: c && (c.link || c.url) }, false)).filter(Boolean),
668 items: (Array.isArray(o.items) ? o.items : []).map((i) => row(i, true)).filter(Boolean),
669 sites: onlineN(o.sites), views_7d: onlineN(o.views_7d),
670 };
671 }
672 /** The strip from the one read: the old facts it carries, who is online, the counts and this site's own items. */
673 function onlineHTML(o) {
674 // Who is on the network, first: a count with a few faces, and (where anyone may be named) the list an old forum
675 // had under its pages: every member by name, then how many more and how many guests. A guest is a number only.
676 let who = '';
677 if (o.total > 0) {
678 const face = (p, size) => (p.avatar ? `<img src="${esc(p.avatar)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async" data-ov-initial="${esc(p.name.charAt(0).toUpperCase())}">` : `<i aria-hidden="true">${esc(p.name.charAt(0).toUpperCase())}</i>`);
679 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(' · ');
680 // Shorthand (the owner, 2026-11-10: "Make it like shorthand '3 online'. If you put your mouse over it, it says
681 // 'last 30 minutes'"): the count and the faces. When, and how many are members and guests, are the tooltip's
682 // and the first line of what a press opens (a finger never sees a tooltip).
683 const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n" data-ov-tip="In the last 30 minutes"${parts ? ` data-ov-tip-sub="${esc(parts)}"` : ''}><b>${fmtN(o.total)}</b> online</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}`;
684 const when = `<p class="ovf-who-when">In the last 30 minutes${parts ? ` · ${parts}` : ''}</p>`;
685 const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');
686 who = `<details class="ovf-who"><summary>${head}</summary>${when}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`;
687 }
688 // Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this
689 // site's own items, and the two facts the strip always had.
690 const tile = (href, cls, inner) => (href ? `<a class="ovf-tile${cls}" href="${esc(href)}">${inner}</a>` : `<span class="ovf-tile${cls}">${inner}</span>`);
691 const row = (r) => tile(r.link, '', `${icon(r.icon)}${r.value != null ? `<b>${esc(fmtN(r.value))}</b>` : ''}<span>${esc(r.label)}</span>${r.line ? `<small>${esc(r.line)}</small>` : ''}`);
692 const tiles = [];
693 const live = o.live;
694 if (live && live.channels > 0) {
695 tiles.push(tile('https://openvibe.live/', ' is-live', `<span class="ovf-dot" aria-hidden="true"></span><b>${fmtN(live.channels)}</b><span>live now</span>${live.watching > 0 ? `<small>${fmtN(live.watching)} watching</small>` : ''}`));
696 live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`)));
697 }
698 o.counts.forEach((c) => tiles.push(row(c)));
699 o.items.forEach((i) => tiles.push(row(i)));
700 if (o.sites > 0) tiles.push(tile(`${NETWORK_URL}/#network`, '', `${icon('sites')}<b>${fmtN(o.sites)}</b><span>sites, one account</span>`));
701 if (o.views_7d > 0) tiles.push(tile('', '', `${icon('chart')}<b>${fmtN(o.views_7d)}</b><span>page views this week</span>`));
702 const more = o.counts.length || o.items.length;
703 return `<div class="ovf-now">${who}${tiles.length ? `<div class="ovf-tiles${more ? ' ovf-online-actions' : ''}">${tiles.join('')}</div>` : ''}</div>`;
704 }
705 function paintPulse() {
706 if (!_mounted) return;
707 const html = _online ? onlineHTML(_online) : (_pulse ? pulseHTML(_pulse) : '');
708 for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) { el.innerHTML = html; el.hidden = !html; }
709 }
710 function readPulse() {
711 if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return;
712 if (_pulse && Date.now() - _pulseAt < PULSE_MS - 1000) { paintPulse(); return; }
713 fetch(PULSE_URL, { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then((p) => {
714 if (!p || typeof p !== 'object') return;
715 const n = (v) => (Number.isFinite(Number(v)) && v !== null ? Math.max(0, Math.floor(Number(v))) : null);
716 _pulse = { live_now: n(p.live_now), watching: n(p.watching), sites: n(p.sites), views_7d: n(p.views_7d) };
717 _pulseAt = Date.now();
718 paintPulse();
719 }).catch(() => { /* a quiet footer is fine */ });
720 }
721 /** The strip's one read; the old pulse endpoint only when it does not answer, until the design lands. */
722 function readNetwork() {
723 if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return;
724 if (_online && Date.now() - _onlineAt < PULSE_MS - 1000) { paintPulse(); return; }
725 // This site's own items come back only when it asks as itself (its service id, or its site id).
726 const site = (_cfg.service || detectService() || '');
727 const url = site ? `${ONLINE_URL}?site=${encodeURIComponent(site)}` : ONLINE_URL;
728 fetch(url, { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then((o) => {
729 const clean = cleanOnline(o);
730 if (!clean) { readPulse(); return; }
731 _online = clean;
732 _onlineAt = Date.now();
733 paintPulse();
734 }).catch(() => { readPulse(); });
735 }
736 function watchPulse() {
737 if (!_mounted || _cfg.pulse === false || typeof window === 'undefined') return;
738 if (_online || _pulse) paintPulse();
739 if (_pulseObserver) return;
740 if (typeof IntersectionObserver !== 'function') { readNetwork(); return; }
741 _pulseObserver = new IntersectionObserver((entries) => {
742 const seen = entries.some(e => e.isIntersecting);
743 clearInterval(_pulseTimer);
744 _pulseTimer = null;
745 if (!seen) return;
746 readNetwork();
747 _pulseTimer = setInterval(readNetwork, PULSE_MS);
748 }, { rootMargin: '200px 0px' });
749 _pulseObserver.observe(_mounted);
750 }
751
752 // shipped.js mounts every [data-ov-shipped] element when it loads; loaded once, lazily, after the footer.
753 let _shippedLoading = false;
754 // shipped.js's content hash (scripts/sibling-revs.js writes it), and the footer's "shipped" line is fetched only when the
755 // footer comes near the screen (4.138.0): most visits never scroll there, and it is 12 KB.
756 const FOOTER_REV = { 'shipped.js': '6b4ed7c3dc2d' };
757 let _shippedSeen = null;
758 function shippedWhenNear(el) {
759 if (typeof IntersectionObserver !== 'function' || !el || !el.getBoundingClientRect) { loadShipped(); return; }
760 if (_shippedSeen) return;
761 _shippedSeen = new IntersectionObserver((es) => { if (es.some((e) => e.isIntersecting)) { _shippedSeen.disconnect(); _shippedSeen = null; loadShipped(); } }, { rootMargin: '900px 0px' });
762 _shippedSeen.observe(el);
763 }
764 function loadShipped() {
765 if (typeof document === 'undefined') return;
766 if (root.OpenVibeShipped && typeof root.OpenVibeShipped.scan === 'function') { try { root.OpenVibeShipped.scan(document); } catch { /* */ } return; }
767 if (_shippedLoading || document.querySelector('script[data-ov-shipped-src]')) return;
768 _shippedLoading = true;
769 const sc = document.createElement('script');
770 let src = `${NETWORK_URL}/shared/shipped.js`;
771 try { if (OWN_SRC) src = new URL('shipped.js', OWN_SRC).href; } catch { /* */ }
772 sc.src = src + (FOOTER_REV['shipped.js'] ? `?v=${FOOTER_REV['shipped.js']}` : '');
773 sc.defer = true;
774 sc.setAttribute('data-ov-shipped-src', '');
775 (document.head || document.documentElement).appendChild(sc);
776 }
777
778 function init(cfg = {}) {
779 _cfg = { ...DEFAULTS, ..._cfg, ...cfg };
780 _cfg.service = _cfg.service || detectService();
781 injectCSS();
782 const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount;
783 if (!el) return null;
784 _mounted = el;
785 // Started: shell.page's own start (after the site's scripts) leaves a footer the site started alone.
786 try { el.setAttribute('data-ovf-live', ''); } catch { /* */ }
787 render();
788 // 'auto' on a single-page site follows its routes: back and forward re-read the path (a route the site pushes
789 // itself calls setVariant or render).
790 if (_cfg.variant === 'auto' && !_routeWatch && typeof window !== 'undefined' && window.addEventListener) {
791 _routeWatch = true;
792 window.addEventListener('popstate', () => { if (_cfg.variant === 'auto' && _mounted) render(); });
793 }
794 return el;
795 }
796 let _routeWatch = false;
797
798 /** The page's own variant (an SPA route's); a claim still wins while it holds. */
799 function setVariant(variant) {
800 if (variant !== 'full' && variant !== 'compact' && variant !== 'auto') return;
801 if (_cfg.variant === variant) return;
802 const before = shownVariant(_cfg.variant);
803 _cfg.variant = variant;
804 if (shownVariant(variant) !== before) render();
805 }
806
807 /**
808 * A part of the page asks for a variant while it needs it: Live's channel claims 'compact' while its stream plays and
809 * releases it when the stream ends or the route changes, and the page's own variant comes back. Same key twice
810 * replaces. → release()
811 */
812 function claim(variant, key = 'default') {
813 if (variant !== 'full' && variant !== 'compact') return () => {};
814 const k = String(key);
815 const before = shownVariant(_cfg.variant);
816 _claims.set(k, variant);
817 if (shownVariant(_cfg.variant) !== before) render();
818 return function release() {
819 if (_claims.get(k) !== variant) return;
820 const was = shownVariant(_cfg.variant);
821 _claims.delete(k);
822 if (shownVariant(_cfg.variant) !== was) render();
823 };
824 }
825
826 /**
827 * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for
828 * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element.
829 * ssr({ service, variant, links, mountId }) → '<style>…</style><footer id="ov-footer" …>…</footer>'
830 */
831 function ssr(cfg = {}) {
832 const c = { ...DEFAULTS, ...cfg };
833 if (c.variant === 'auto') c.variant = autoFor(cfg.path, c.fullOn); // the request's path, on the server
834 const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, '');
835 return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;
836 }
837
838 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); } };
839});
840