openvibers/openvibe.live Commits
Paste links: signed-in users arrive signed in on Community (silent sign-in hand-over); CSP allows the Network sign-in check frame; vendor sync
Changes
@@@ -1094,6 +1094,14 @@ function whenRouteReady(pageId, render) {});}++/** Pastes live on openvibe.community. A signed-in user goes through its silent sign-in so they arrive++ * signed in; guests go straight to the page. */++function _pasteHandOver(target) {++ const base = document.querySelector('meta[name="ov-pastes-base"]').content;++ const signedIn = /(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(document.cookie) || !!(typeof currentUser !== 'undefined' && currentUser);++ location.replace(signedIn ? `${base}/auth/login?silent=1&next=${encodeURIComponent(target)}` : base + target);++}++function routeFromURL() {// Remove the server-rendered SEO prerender block once the SPA takes over (it's crawlable// content for no-JS scrapers; JS clients render the real interactive page instead).@@@ -1199,11 +1207,11 @@ function routeFromURL() {window.location.replace('/privacy');return;} else if (segments[0] === 'pastes' && !segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {-- location.replace(`${document.querySelector('meta[name="ov-pastes-base"]').content}/pastes${location.search}`);++ _pasteHandOver(`/pastes${location.search}`);return;} else if (segments[0] === 'p' && segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {// Pastes live on openvibe.community now — hand the browser over (same slug, same URL shape).-- location.replace(`${document.querySelector('meta[name="ov-pastes-base"]').content}/p/${encodeURIComponent(segments[1])}${location.search}`);++ _pasteHandOver(`/p/${encodeURIComponent(segments[1])}${location.search}`);return;} else if (segments[0] === 'p' && segments[1]) {showPage('paste-viewer');@@@ -259,7 +259,7 @@ app.use(helmet({// data: — the mod TTS voice preview plays a data:audio/… URL; without it the browser// rejects the element ("no supported source") even though the backend returned audio.mediaSrc: ["'self'", "blob:", "data:", "https://openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],-- frameSrc: ["'self'", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com"],++ frameSrc: ["'self'", "https://openvibe.network", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com"],workerSrc: ["'self'", "blob:"],scriptSrcAttr: ["'unsafe-inline'"],},@@@ -612,8 +612,17 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {// working as permanent redirects, so links in chat, search results and clipboards survive.const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');if (process.env.PASTES_ON_COMMUNITY === '1') {-- app.get('/p/:slug', (req, res) => res.redirect(301, `${COMMUNITY_URL}/p/${encodeURIComponent(req.params.slug)}`));-- app.get('/pastes', (req, res) => res.redirect(301, `${COMMUNITY_URL}/pastes`));++ // Someone signed in here should arrive signed in there: they go through Community's silent sign-in++ // (one quiet round trip; it skips itself when Community already has a valid session and falls back++ // to the plain page when the Network session is gone). Guests and crawlers get the permanent redirect.++ const handOver = (req, res, target) => {++ const signedIn = /(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''));++ if (!signedIn) return res.redirect(301, `${COMMUNITY_URL}${target}`);++ res.set({ 'Cache-Control': 'private, no-store', Vary: 'Cookie' });++ res.redirect(302, `${COMMUNITY_URL}/auth/login?silent=1&next=${encodeURIComponent(target)}`);++ };++ app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));++ app.get('/pastes', (req, res) => handOver(req, res, '/pastes'));}// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)@@@ -64,11 +64,18 @@ function manifest({ site = 'network', name, shortName, description, startUrl = '};}++// First paint must never depend on another server. These two inline tags give every page the network's++// dark canvas and the visitor's cached theme immediately; theme-loader.js (deferred, from the Network)++// reconciles with the account afterwards. If the Network is slow or restarting, the page is still++// styled and readable instead of blank white.++const CRITICAL = `<style id="ov-critical">html{background:${BG};color:#e6edf7;color-scheme:dark}</style>`++ + `<script>(function(){try{var r=localStorage.getItem('ov_theme');if(!r)return;var t=JSON.parse(r),v=t&&t.variables;if(!v)return;var e=document.documentElement;for(var k in v)if(k.charAt(0)==='-')e.style.setProperty(k,v[k]);if(v['--bg-primary'])e.style.background=v['--bg-primary'];if(v['--text-primary'])e.style.color=v['--text-primary'];if(v['--color-scheme']==='light'||v['--color-scheme']==='dark')e.style.colorScheme=v['--color-scheme'];if(t.id)e.setAttribute('data-theme',t.id)}catch(_){}})();</script>`;++/** <head> tags every site shares: SVG favicon (inline, no request), theme colour, and the PNG touch icon when the site ships one. */function headTags({ site = 'network', iconBase = null } = {}) {const uri = 'data:image/svg+xml,' + encodeURIComponent(favicon({ site })).replace(/'/g, '%27');-- return [`<link rel="icon" type="image/svg+xml" data-ov-icon="${String(site).replace(/[^a-z]/g, '')}" href="${uri}">`, '<meta name="theme-color" content="#0d131d">', `<meta name="color-scheme" content="dark light">`,++ return [`<link rel="icon" type="image/svg+xml" data-ov-icon="${String(site).replace(/[^a-z]/g, '')}" href="${uri}">`, '<meta name="theme-color" content="#0d131d">', '<meta name="color-scheme" content="dark">', CRITICAL,iconBase ? `<link rel="apple-touch-icon" href="${iconBase}/logo-192.png">` : '', iconBase ? `<link rel="manifest" href="/manifest.webmanifest">` : ''].filter(Boolean).join('\n');}--module.exports = { svg, favicon, manifest, headTags, SITES, BG };++module.exports = { svg, favicon, manifest, headTags, CRITICAL, SITES, BG };@@@ -179,7 +179,7 @@ function page(kind, site) {return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${head}${require('./app-icon').headTags({ site: site.id })}--<script src="https://openvibe.network/shared/theme-loader.js"></script><style>${CSS}</style></head><body>++<script src="https://openvibe.network/shared/theme-loader.js" defer></script><style>${CSS}</style></head><body><noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript><main>${article(kind, site)}</main><div id="ov-footer"></div><script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>@@@ -107,6 +107,10 @@.ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }.ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }.ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }++ .ovnav-launcher .ovl-addr a { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }++ .ovnav-launcher .ovl-addr a.is-here { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); }++ .ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }@@@ -613,17 +617,19 @@{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },];-- const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';++ const CATALOG_URL = 'https://openvibe.network/api/catalog.json'; // allowed by every site's CSP++ const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };async function launcherCatalog() {-- try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }++ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog2') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }try {const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;const j = await r.json();const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),-- tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };-- try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }++ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), go: t.hosts && t.hosts.short ? okUrl('https://' + t.hosts.short + '/') : null,++ hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter(h => typeof h === 'string' && h), id: String(t.id || ''), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };++ try { sessionStorage.setItem('ov_catalog2', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }return data;} catch { return null; }}@@@ -631,20 +637,33 @@function bindLauncher(nav) {const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;let panel = null;-- const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;++ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.go || it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };function paint(cat, q) {const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);const body = panel.querySelector('.ovl-body');-- if (q && cat) {-- const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 12);-- body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';++ if (q) {++ // Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),++ // the last row hands the query to the Tools search page, so typing never does nothing.++ const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);++ const siteList = (OVChrome.get() && OVChrome.get().nav.length ? OVChrome.get().nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES).filter(hit);++ const famList = fams.filter(hit);++ const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];++ const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;++ body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-fams">${siteList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')++ + (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')++ + `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;return;}const chrome = OVChrome.get();const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;const soon = chrome ? chrome.soon : [];-- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>++ // On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people++ // can pick the one they will remember.++ const cur = currentHost().toLowerCase();++ const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;++ const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';++ body.innerHTML = addresses + `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div><div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;}@@@ -660,7 +679,7 @@let cat = null; paint(null, '');const input = panel.querySelector('.ovl-q');input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));-- input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });++ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); location.href = first ? first.href : TOOLS_SEARCH + encodeURIComponent(input.value.trim()); } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });panel.addEventListener('keydown', (ev) => {if (ev.key === 'Escape') { close(); btn.focus(); return; }if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;@@@ -108,6 +108,9 @@// Native controls, scrollbars and form fields follow the theme's light/dark mode.const scheme = vars['--color-scheme'];if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }++ // The inline critical style paints a fixed dark canvas before any stylesheet; keep it in step with the theme.++ if (vars['--bg-primary']) el.style.background = vars['--bg-primary'];++ if (vars['--text-primary']) el.style.color = vars['--text-primary'];syncChrome();}
@@ -1094,6 +1094,14 @@ function whenRouteReady(pageId, render) {
@@ -1094,6 +1094,14 @@ function whenRouteReady(pageId, render) {
});
});
}
}
+/** Pastes live on openvibe.community. A signed-in user goes through its silent sign-in so they arrive
+ * signed in; guests go straight to the page. */
+function _pasteHandOver(target) {
+ const base = document.querySelector('meta[name="ov-pastes-base"]').content;
+ const signedIn = /(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(document.cookie) || !!(typeof currentUser !== 'undefined' && currentUser);
+ location.replace(signedIn ? `${base}/auth/login?silent=1&next=${encodeURIComponent(target)}` : base + target);
+}
+
function routeFromURL() {
function routeFromURL() {
// Remove the server-rendered SEO prerender block once the SPA takes over (it's crawlable
// Remove the server-rendered SEO prerender block once the SPA takes over (it's crawlable
// content for no-JS scrapers; JS clients render the real interactive page instead).
// content for no-JS scrapers; JS clients render the real interactive page instead).
@@ -1199,11 +1207,11 @@ function routeFromURL() {
@@ -1199,11 +1207,11 @@ function routeFromURL() {
window.location.replace('/privacy');
window.location.replace('/privacy');
return;
return;
} else if (segments[0] === 'pastes' && !segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {
} else if (segments[0] === 'pastes' && !segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {
- location.replace(`${document.querySelector('meta[name="ov-pastes-base"]').content}/pastes${location.search}`);
+ _pasteHandOver(`/pastes${location.search}`);
return;
return;
} else if (segments[0] === 'p' && segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {
} else if (segments[0] === 'p' && segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {
// Pastes live on openvibe.community now — hand the browser over (same slug, same URL shape).
// Pastes live on openvibe.community now — hand the browser over (same slug, same URL shape).
- location.replace(`${document.querySelector('meta[name="ov-pastes-base"]').content}/p/${encodeURIComponent(segments[1])}${location.search}`);
+ _pasteHandOver(`/p/${encodeURIComponent(segments[1])}${location.search}`);
return;
return;
} else if (segments[0] === 'p' && segments[1]) {
} else if (segments[0] === 'p' && segments[1]) {
showPage('paste-viewer');
showPage('paste-viewer');
@@ -259,7 +259,7 @@ app.use(helmet({
@@ -259,7 +259,7 @@ app.use(helmet({
// data: — the mod TTS voice preview plays a data:audio/… URL; without it the browser
// data: — the mod TTS voice preview plays a data:audio/… URL; without it the browser
// rejects the element ("no supported source") even though the backend returned audio.
// rejects the element ("no supported source") even though the backend returned audio.
mediaSrc: ["'self'", "blob:", "data:", "https://openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],
mediaSrc: ["'self'", "blob:", "data:", "https://openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],
- frameSrc: ["'self'", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com"],
+ frameSrc: ["'self'", "https://openvibe.network", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com"],
workerSrc: ["'self'", "blob:"],
workerSrc: ["'self'", "blob:"],
scriptSrcAttr: ["'unsafe-inline'"],
scriptSrcAttr: ["'unsafe-inline'"],
},
},
@@ -612,8 +612,17 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
@@ -612,8 +612,17 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
// working as permanent redirects, so links in chat, search results and clipboards survive.
// working as permanent redirects, so links in chat, search results and clipboards survive.
const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
if (process.env.PASTES_ON_COMMUNITY === '1') {
if (process.env.PASTES_ON_COMMUNITY === '1') {
- app.get('/p/:slug', (req, res) => res.redirect(301, `${COMMUNITY_URL}/p/${encodeURIComponent(req.params.slug)}`));
- app.get('/pastes', (req, res) => res.redirect(301, `${COMMUNITY_URL}/pastes`));
+ // Someone signed in here should arrive signed in there: they go through Community's silent sign-in
+ // (one quiet round trip; it skips itself when Community already has a valid session and falls back
+ // to the plain page when the Network session is gone). Guests and crawlers get the permanent redirect.
+ const handOver = (req, res, target) => {
+ const signedIn = /(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''));
+ if (!signedIn) return res.redirect(301, `${COMMUNITY_URL}${target}`);
+ res.set({ 'Cache-Control': 'private, no-store', Vary: 'Cookie' });
+ res.redirect(302, `${COMMUNITY_URL}/auth/login?silent=1&next=${encodeURIComponent(target)}`);
+ };
+ app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));
+ app.get('/pastes', (req, res) => handOver(req, res, '/pastes'));
}
}
// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
@@ -64,11 +64,18 @@ function manifest({ site = 'network', name, shortName, description, startUrl = '
@@ -64,11 +64,18 @@ function manifest({ site = 'network', name, shortName, description, startUrl = '
};
};
}
}
+// First paint must never depend on another server. These two inline tags give every page the network's
+// dark canvas and the visitor's cached theme immediately; theme-loader.js (deferred, from the Network)
+// reconciles with the account afterwards. If the Network is slow or restarting, the page is still
+// styled and readable instead of blank white.
+const CRITICAL = `<style id="ov-critical">html{background:${BG};color:#e6edf7;color-scheme:dark}</style>`
+ + `<script>(function(){try{var r=localStorage.getItem('ov_theme');if(!r)return;var t=JSON.parse(r),v=t&&t.variables;if(!v)return;var e=document.documentElement;for(var k in v)if(k.charAt(0)==='-')e.style.setProperty(k,v[k]);if(v['--bg-primary'])e.style.background=v['--bg-primary'];if(v['--text-primary'])e.style.color=v['--text-primary'];if(v['--color-scheme']==='light'||v['--color-scheme']==='dark')e.style.colorScheme=v['--color-scheme'];if(t.id)e.setAttribute('data-theme',t.id)}catch(_){}})();</script>`;
+
/** <head> tags every site shares: SVG favicon (inline, no request), theme colour, and the PNG touch icon when the site ships one. */
/** <head> tags every site shares: SVG favicon (inline, no request), theme colour, and the PNG touch icon when the site ships one. */
function headTags({ site = 'network', iconBase = null } = {}) {
function headTags({ site = 'network', iconBase = null } = {}) {
const uri = 'data:image/svg+xml,' + encodeURIComponent(favicon({ site })).replace(/'/g, '%27');
const uri = 'data:image/svg+xml,' + encodeURIComponent(favicon({ site })).replace(/'/g, '%27');
- return [`<link rel="icon" type="image/svg+xml" data-ov-icon="${String(site).replace(/[^a-z]/g, '')}" href="${uri}">`, '<meta name="theme-color" content="#0d131d">', `<meta name="color-scheme" content="dark light">`,
+ return [`<link rel="icon" type="image/svg+xml" data-ov-icon="${String(site).replace(/[^a-z]/g, '')}" href="${uri}">`, '<meta name="theme-color" content="#0d131d">', '<meta name="color-scheme" content="dark">', CRITICAL,
iconBase ? `<link rel="apple-touch-icon" href="${iconBase}/logo-192.png">` : '', iconBase ? `<link rel="manifest" href="/manifest.webmanifest">` : ''].filter(Boolean).join('\n');
iconBase ? `<link rel="apple-touch-icon" href="${iconBase}/logo-192.png">` : '', iconBase ? `<link rel="manifest" href="/manifest.webmanifest">` : ''].filter(Boolean).join('\n');
}
}
-module.exports = { svg, favicon, manifest, headTags, SITES, BG };
+module.exports = { svg, favicon, manifest, headTags, CRITICAL, SITES, BG };
@@ -179,7 +179,7 @@ function page(kind, site) {
@@ -179,7 +179,7 @@ function page(kind, site) {
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
${head}
${head}
${require('./app-icon').headTags({ site: site.id })}
${require('./app-icon').headTags({ site: site.id })}
-<script src="https://openvibe.network/shared/theme-loader.js"></script><style>${CSS}</style></head><body>
+<script src="https://openvibe.network/shared/theme-loader.js" defer></script><style>${CSS}</style></head><body>
<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>
<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>
<main>${article(kind, site)}</main><div id="ov-footer"></div>
<main>${article(kind, site)}</main><div id="ov-footer"></div>
<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>
<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>
@@ -107,6 +107,10 @@
@@ -107,6 +107,10 @@
.ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
.ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
.ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
.ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
.ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }
.ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }
+ .ovnav-launcher .ovl-addr a { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }
+ .ovnav-launcher .ovl-addr a.is-here { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); }
+ .ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@@ -613,17 +617,19 @@
@@ -613,17 +617,19 @@
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
];
];
- const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';
+ const CATALOG_URL = 'https://openvibe.network/api/catalog.json'; // allowed by every site's CSP
+ const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';
const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
async function launcherCatalog() {
async function launcherCatalog() {
- try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
+ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog2') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
try {
try {
const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
const j = await r.json();
const j = await r.json();
const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),
const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),
- tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
- try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
+ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), go: t.hosts && t.hosts.short ? okUrl('https://' + t.hosts.short + '/') : null,
+ hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter(h => typeof h === 'string' && h), id: String(t.id || ''), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
+ try { sessionStorage.setItem('ov_catalog2', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
return data;
return data;
} catch { return null; }
} catch { return null; }
}
}
@@ -631,20 +637,33 @@
@@ -631,20 +637,33 @@
function bindLauncher(nav) {
function bindLauncher(nav) {
const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
let panel = null;
let panel = null;
- const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
+ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.go || it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
function paint(cat, q) {
function paint(cat, q) {
const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
const body = panel.querySelector('.ovl-body');
const body = panel.querySelector('.ovl-body');
- if (q && cat) {
- const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 12);
- body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
+ if (q) {
+ // Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),
+ // the last row hands the query to the Tools search page, so typing never does nothing.
+ const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);
+ const siteList = (OVChrome.get() && OVChrome.get().nav.length ? OVChrome.get().nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES).filter(hit);
+ const famList = fams.filter(hit);
+ const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];
+ const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;
+ body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-fams">${siteList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
+ + (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
+ + `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;
return;
return;
}
}
const chrome = OVChrome.get();
const chrome = OVChrome.get();
const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
const soon = chrome ? chrome.soon : [];
const soon = chrome ? chrome.soon : [];
- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
+ // On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people
+ // can pick the one they will remember.
+ const cur = currentHost().toLowerCase();
+ const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;
+ const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';
+ body.innerHTML = addresses + `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
<div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
<div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
}
}
@@ -660,7 +679,7 @@
@@ -660,7 +679,7 @@
let cat = null; paint(null, '');
let cat = null; paint(null, '');
const input = panel.querySelector('.ovl-q');
const input = panel.querySelector('.ovl-q');
input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
- input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });
+ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); location.href = first ? first.href : TOOLS_SEARCH + encodeURIComponent(input.value.trim()); } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });
panel.addEventListener('keydown', (ev) => {
panel.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') { close(); btn.focus(); return; }
if (ev.key === 'Escape') { close(); btn.focus(); return; }
if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
@@ -108,6 +108,9 @@
@@ -108,6 +108,9 @@
// Native controls, scrollbars and form fields follow the theme's light/dark mode.
// Native controls, scrollbars and form fields follow the theme's light/dark mode.
const scheme = vars['--color-scheme'];
const scheme = vars['--color-scheme'];
if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }
if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }
+ // The inline critical style paints a fixed dark canvas before any stylesheet; keep it in step with the theme.
+ if (vars['--bg-primary']) el.style.background = vars['--bg-primary'];
+ if (vars['--text-primary']) el.style.color = vars['--text-primary'];
syncChrome();
syncChrome();
}
}