openvibers/openvibe.media Commits

Footer and no-JS navigation baked into the HTML; vendor sync

OP OpenVibers committed 18 Sept 2026 · 79d1e219d7 · 9 files, +127 −244


Changes

ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex 901b8c3..98a9c4a 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -268,12 +268,12 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}
</head><body>
--<div id="navbar-mount"></div>
++<div id="navbar-mount"></div>${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Media' })}
<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, images, pastes &amp; thumbnails from all OpenVibe sites</span></header>
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
--<footer id="ov-footer" class="ovf"></footer>
++${require('openvibe-shared/footer').ssr({ service: 'media', variant: 'full' })}
${require('./page-chrome').chromeScripts({ footer: { variant: 'full' } })}
</body></html>`;
}
ddiff --git a/server/public/page-chrome.js b/server/public/page-chrome.js
iindex 96dddbf..dc2fdce 100644
---- a/server/public/page-chrome.js
++++ b/server/public/page-chrome.js
@@@ -184,11 +184,11 @@ ${headTags(seo)}
<style>${baseCss()}${css}</style>
</head>
<body>
--<div id="navbar-mount"></div>
++<div id="navbar-mount"></div>${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Media', links: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }] })}
<main>
${body}
</main>
--<footer id="ov-footer" class="ovf"></footer>
++${require('openvibe-shared/footer').ssr({ service: 'media', variant: 'compact' })}
${chromeScripts({ history, footer })}
</body>
</html>`;
ddiff --git a/vendor/openvibe-shared/chrome-ssr.js b/vendor/openvibe-shared/chrome-ssr.js
nnew file mode 100644
iindex 0000000..d06798b
---- /dev/null
++++ b/vendor/openvibe-shared/chrome-ssr.js
@@@ -0,0 +1,17 @@
++'use strict';
++// openvibe-shared/chrome-ssr — the parts of the shared chrome that can be baked into HTML on the server.
++// footer(cfg) the full shared footer (HTML + CSS); the browser's footer.js renders into the same element
++// noscriptNav(site) a plain navigation bar inside <noscript>: the JavaScript navbar cannot exist without
++// scripts, so visitors who block them (and text browsers) still get the brand and the network
++const footerModule = require('./footer');
++
++const SITES = [['Live', 'https://openvibe.live/'], ['Tools', 'https://openvibe.tools/'], ['Community', 'https://openvibe.community/'], ['Games', 'https://openvibe.games/'], ['Media', 'https://openvibe.media/'], ['Network', 'https://openvibe.network/']];
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++function noscriptNav({ name, home = '/', links = [] } = {}) {
++ const own = links.map(l => `<a href="${esc(l.href)}">${esc(l.label)}</a>`).join('');
++ const net = SITES.filter(([n]) => !String(name || '').endsWith('.' + n)).map(([n, u]) => `<a href="${u}">${n}</a>`).join('');
++ return `<noscript><nav aria-label="Site" style="display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.1);font:500 14px/1.4 system-ui,sans-serif"><a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>${own}<span style="flex:1"></span>${net}</nav></noscript>`;
++}
++
++module.exports = { footer: (cfg) => footerModule.ssr(cfg), noscriptNav };
ddiff --git a/vendor/openvibe-shared/footer.js b/vendor/openvibe-shared/footer.js
iindex f0777db..e560bd0 100644
---- a/vendor/openvibe-shared/footer.js
++++ b/vendor/openvibe-shared/footer.js
@@@ -47,6 +47,7 @@
}
function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
function refresh() {
++ if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call out
if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
.then(r => (r.ok ? r.json() : null)).then(clean)
@@@ -379,5 +380,16 @@
render();
}
-- return { init, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
++ /**
++ * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for
++ * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element.
++ * ssr({ service, variant, links, mountId }) → '<style>…</style><footer id="ov-footer" …>…</footer>'
++ */
++ function ssr(cfg = {}) {
++ const c = { ...DEFAULTS, ...cfg };
++ const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, '');
++ return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;
++ }
++
++ return { init, ssr, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
});
ddiff --git a/vendor/openvibe-shared/legal.js b/vendor/openvibe-shared/legal.js
iindex c419fa0..39888bf 100644
---- a/vendor/openvibe-shared/legal.js
++++ b/vendor/openvibe-shared/legal.js
@@@ -180,8 +180,8 @@ function page(kind, site) {
${head}
${require('./app-icon').headTags({ site: site.id })}
<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>
++${require('./chrome-ssr').noscriptNav({ name: site.name })}
++<main>${article(kind, site)}</main>${require('./footer').ssr({ service: site.service || site.id || 'network', variant: 'compact' })}
<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>
<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}
try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
iindex 64466bb..9713e6a 100644
---- a/vendor/openvibe-shared/navbar.js
++++ b/vendor/openvibe-shared/navbar.js
@@@ -83,6 +83,7 @@
.ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
@media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
++ .openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; font-size: 1.05em; vertical-align: -.15em; }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
@@@ -255,225 +256,30 @@
document.head.appendChild(s);
}
-- // Primary services (per CONTRACTS branding) + tool sub-brands (<Name>.OpenVibe)
-- const SERVICE_NAMES = {
-- live: 'OpenVibe.Live', tools: 'OpenVibe.Tools', games: 'OpenVibe.Games',
-- media: 'OpenVibe.Media', network: 'OpenVibe.Network',
-- net: 'Net.OpenVibe', dev: 'Dev.OpenVibe', paste: 'Paste.OpenVibe',
-- maps: 'Maps.OpenVibe', food: 'Food.OpenVibe', img: 'Img.OpenVibe',
-- yt: 'YT.OpenVibe', audio: 'Audio.OpenVibe', text: 'Text.OpenVibe',
-- logo: 'Logo.OpenVibe', docs: 'Docs.OpenVibe',
-- };
--
-- const SERVICE_ICONS = {
-- live: 'fa-tower-broadcast', tools: 'fa-screwdriver-wrench', games: 'fa-gamepad',
-- media: 'fa-photo-film', network: 'fa-circle-nodes',
-- net: 'fa-network-wired', dev: 'fa-code', paste: 'fa-paste',
-- maps: 'fa-map-location-dot', food: 'fa-utensils', img: 'fa-images',
-- yt: 'fa-circle-play', audio: 'fa-headphones', text: 'fa-pen-fancy',
-- logo: 'fa-wand-magic-sparkles', docs: 'fa-file-pdf',
-- };
--
-- // Subdomain → brand override for multi-subdomain services (Img.OpenVibe)
-- const SUBDOMAIN_BRANDS = {
-- 'png.openvibe.tools': { name: 'OpenVibePNG', icon: 'fa-file-image' },
-- 'jpg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },
-- 'jpeg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },
-- 'webp.openvibe.tools': { name: 'OpenVibeWebP', icon: 'fa-file-image' },
-- 'avif.openvibe.tools': { name: 'OpenVibeAVIF', icon: 'fa-file-image' },
-- 'heic.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },
-- 'heif.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },
-- 'svg.openvibe.tools': { name: 'OpenVibeSVG', icon: 'fa-bezier-curve' },
-- 'gif.openvibe.tools': { name: 'OpenVibeGIF', icon: 'fa-film' },
-- 'ico.openvibe.tools': { name: 'OpenVibeICO', icon: 'fa-icons' },
-- 'tiff.openvibe.tools': { name: 'OpenVibeTIFF', icon: 'fa-file-image' },
-- 'bmp.openvibe.tools': { name: 'OpenVibeBMP', icon: 'fa-file-image' },
-- 'compress.openvibe.tools': { name: 'OpenVibeCompress', icon: 'fa-compress' },
-- 'resize.openvibe.tools': { name: 'OpenVibeResize', icon: 'fa-up-right-and-down-left-from-center' },
-- 'crop.openvibe.tools': { name: 'OpenVibeCrop', icon: 'fa-crop-simple' },
-- 'convert.openvibe.tools': { name: 'OpenVibeConvert', icon: 'fa-arrows-rotate' },
-- 'favicon.openvibe.tools': { name: 'OpenVibeFavicon', icon: 'fa-icons' },
-- 'yt.openvibe.tools': { name: 'YT.OpenVibe', icon: 'fa-circle-play' },
-- 'maps.openvibe.tools': { name: 'Maps.OpenVibe', icon: 'fa-map-location-dot' },
-- 'food.openvibe.tools': { name: 'Food.OpenVibe', icon: 'fa-utensils' },
-- // Audio tool subdomains
-- 'audio.openvibe.tools': { name: 'Audio.OpenVibe', icon: 'fa-headphones' },
-- 'mp3.openvibe.tools': { name: 'OpenVibeMP3', icon: 'fa-file-audio' },
-- 'wav.openvibe.tools': { name: 'OpenVibeWAV', icon: 'fa-file-audio' },
-- 'flac.openvibe.tools': { name: 'OpenVibeFLAC', icon: 'fa-file-audio' },
-- 'ogg.openvibe.tools': { name: 'OpenVibeOGG', icon: 'fa-file-audio' },
-- 'm4a.openvibe.tools': { name: 'OpenVibeM4A', icon: 'fa-file-audio' },
-- 'aac.openvibe.tools': { name: 'OpenVibeAAC', icon: 'fa-file-audio' },
-- 'opus.openvibe.tools': { name: 'OpenVibeOPUS', icon: 'fa-file-audio' },
-- 'wma.openvibe.tools': { name: 'OpenVibeWMA', icon: 'fa-file-audio' },
-- 'aiff.openvibe.tools': { name: 'OpenVibeAIFF', icon: 'fa-file-audio' },
-- 'ac3.openvibe.tools': { name: 'OpenVibeAC3', icon: 'fa-file-audio' },
-- 'trim.openvibe.tools': { name: 'OpenVibeTrim', icon: 'fa-scissors' },
-- 'pitch.openvibe.tools': { name: 'OpenVibePitch', icon: 'fa-wave-square' },
-- 'speed.openvibe.tools': { name: 'OpenVibeSpeed', icon: 'fa-gauge-high' },
-- 'normalize.openvibe.tools':{ name: 'OpenVibeNormalize', icon: 'fa-sliders' },
-- 'fade.openvibe.tools': { name: 'OpenVibeFade', icon: 'fa-volume-low' },
-- 'bass.openvibe.tools': { name: 'OpenVibeBass', icon: 'fa-volume-high' },
-- 'equalizer.openvibe.tools':{ name: 'OpenVibeEQ', icon: 'fa-bars-staggered' },
-- 'echo.openvibe.tools': { name: 'OpenVibeEcho', icon: 'fa-tower-broadcast' },
-- 'reverb.openvibe.tools': { name: 'OpenVibeReverb', icon: 'fa-church' },
-- 'voice.openvibe.tools': { name: 'OpenVibeVoiceFX', icon: 'fa-user-astronaut' },
-- 'extract.openvibe.tools': { name: 'OpenVibeExtract', icon: 'fa-music' },
-- 'ringtone.openvibe.tools': { name: 'OpenVibeRingtone', icon: 'fa-bell' },
-- // Text tool subdomains
-- 'text.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },
-- 'type.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },
-- 'fonts.openvibe.tools': { name: 'OpenVibeFonts', icon: 'fa-font' },
-- 'fancy.openvibe.tools': { name: 'OpenVibeFancy', icon: 'fa-wand-sparkles' },
-- 'zalgo.openvibe.tools': { name: 'OpenVibeZalgo', icon: 'fa-skull' },
-- 'ascii.openvibe.tools': { name: 'OpenVibeASCII', icon: 'fa-terminal' },
-- 'symbols.openvibe.tools': { name: 'OpenVibeSymbols', icon: 'fa-icons' },
-- 'unicode.openvibe.tools': { name: 'OpenVibeUnicode', icon: 'fa-magnifying-glass' },
-- 'bubble.openvibe.tools': { name: 'OpenVibeBubble', icon: 'fa-circle' },
-- 'glitch.openvibe.tools': { name: 'OpenVibeGlitch', icon: 'fa-bug' },
-- 'smallcaps.openvibe.tools': { name: 'OpenVibeSmallCaps', icon: 'fa-text-height' },
-- 'cursive.openvibe.tools': { name: 'OpenVibeCursive', icon: 'fa-pen-nib' },
-- 'gothic.openvibe.tools': { name: 'OpenVibeGothic', icon: 'fa-book-skull' },
-- 'wide.openvibe.tools': { name: 'OpenVibeWide', icon: 'fa-arrows-left-right' },
-- 'monospaced.openvibe.tools': { name: 'OpenVibeMono', icon: 'fa-code' },
-- 'braille.openvibe.tools': { name: 'OpenVibeBraille', icon: 'fa-braille' },
-- 'morse.openvibe.tools': { name: 'OpenVibeMorse', icon: 'fa-tower-broadcast' },
-- 'binary.openvibe.tools': { name: 'OpenVibeBinary', icon: 'fa-microchip' },
-- 'case.openvibe.tools': { name: 'OpenVibeCase', icon: 'fa-text-height' },
-- 'caps.openvibe.tools': { name: 'OpenVibeCaps', icon: 'fa-text-height' },
-- 'titlecase.openvibe.tools': { name: 'OpenVibeTitleCase', icon: 'fa-heading' },
-- 'reverse.openvibe.tools': { name: 'OpenVibeReverse', icon: 'fa-right-left' },
-- 'clean.openvibe.tools': { name: 'OpenVibeClean', icon: 'fa-broom' },
-- 'strip.openvibe.tools': { name: 'OpenVibeStrip', icon: 'fa-broom' },
-- 'count.openvibe.tools': { name: 'OpenVibeCount', icon: 'fa-calculator' },
-- 'lines.openvibe.tools': { name: 'OpenVibeLines', icon: 'fa-list-ol' },
-- 'sort.openvibe.tools': { name: 'OpenVibeSort', icon: 'fa-arrow-down-a-z' },
-- 'dedupe.openvibe.tools': { name: 'OpenVibeDedupe', icon: 'fa-filter' },
-- 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },
-- 'compare.openvibe.tools': { name: 'OpenVibeCompare', icon: 'fa-code-compare' },
-- 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
-- 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
-- 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-brackets-curly' },
-- 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
-- 'bio.openvibe.tools': { name: 'OpenVibeBio', icon: 'fa-id-card' },
-- 'nickname.openvibe.tools': { name: 'OpenVibeNickname', icon: 'fa-signature' },
-- 'username.openvibe.tools': { name: 'OpenVibeUsername', icon: 'fa-at' },
-- 'gamertag.openvibe.tools': { name: 'OpenVibeGamertag', icon: 'fa-gamepad' },
-- 'kaomoji.openvibe.tools': { name: 'OpenVibeKaomoji', icon: 'fa-face-smile' },
-- 'emojis.openvibe.tools': { name: 'OpenVibeEmojis', icon: 'fa-face-grin' },
-- 'copypaste.openvibe.tools': { name: 'OpenVibeCopyPaste', icon: 'fa-paste' },
-- 'banner.openvibe.tools': { name: 'OpenVibeBanner', icon: 'fa-rectangle-ad' },
-- 'textart.openvibe.tools': { name: 'Text.OpenVibeArt', icon: 'fa-border-all' },
-- 'figlet.openvibe.tools': { name: 'OpenVibeFiglet', icon: 'fa-terminal' },
-- // Logo / design subdomains
-- 'logo.openvibe.tools': { name: 'Logo.OpenVibe', icon: 'fa-wand-magic-sparkles' },
-- 'title.openvibe.tools': { name: 'OpenVibeTitle', icon: 'fa-heading' },
-- 'wordmark.openvibe.tools': { name: 'OpenVibeWordmark', icon: 'fa-font' },
-- 'textlogo.openvibe.tools': { name: 'Text.OpenVibeLogo', icon: 'fa-font' },
-- 'transparent.openvibe.tools':{ name: 'OpenVibeTransparent', icon: 'fa-eye-slash' },
-- 'badge.openvibe.tools': { name: 'OpenVibeBadge', icon: 'fa-certificate' },
-- 'sticker.openvibe.tools': { name: 'OpenVibeSticker', icon: 'fa-note-sticky' },
-- 'thumbnail.openvibe.tools': { name: 'OpenVibeThumbnail', icon: 'fa-photo-film' },
-- 'cover.openvibe.tools': { name: 'OpenVibeCover', icon: 'fa-image' },
-- 'channelart.openvibe.tools': { name: 'OpenVibeChannelArt', icon: 'fa-panorama' },
-- 'watermark.openvibe.tools': { name: 'OpenVibeWatermark', icon: 'fa-droplet' },
-- 'neon.openvibe.tools': { name: 'OpenVibeNeon', icon: 'fa-lightbulb' },
-- // Document / PDF subdomains
-- 'docs.openvibe.tools': { name: 'Docs.OpenVibe', icon: 'fa-file-pdf' },
-- 'pdf.openvibe.tools': { name: 'OpenVibePDF', icon: 'fa-file-pdf' },
-- 'mergepdf.openvibe.tools': { name: 'MergePDF', icon: 'fa-object-group' },
-- 'splitpdf.openvibe.tools': { name: 'SplitPDF', icon: 'fa-scissors' },
-- 'compresspdf.openvibe.tools':{ name: 'CompressPDF', icon: 'fa-compress' },
-- 'rotatepdf.openvibe.tools': { name: 'RotatePDF', icon: 'fa-rotate' },
-- 'reorderpdf.openvibe.tools': { name: 'ReorderPDF', icon: 'fa-sort' },
-- 'watermarkpdf.openvibe.tools':{ name: 'WatermarkPDF', icon: 'fa-stamp' },
-- 'protectpdf.openvibe.tools': { name: 'ProtectPDF', icon: 'fa-lock' },
-- 'unlockpdf.openvibe.tools': { name: 'UnlockPDF', icon: 'fa-lock-open' },
-- 'image2pdf.openvibe.tools': { name: 'Image2PDF', icon: 'fa-file-image' },
-- 'jpg2pdf.openvibe.tools': { name: 'JPG2PDF', icon: 'fa-file-image' },
-- 'png2pdf.openvibe.tools': { name: 'PNG2PDF', icon: 'fa-file-image' },
-- 'pdf2jpg.openvibe.tools': { name: 'PDF2JPG', icon: 'fa-image' },
-- 'pdf2png.openvibe.tools': { name: 'PDF2PNG', icon: 'fa-image' },
-- // Network tool subdomains
-- 'net.openvibe.tools': { name: 'Net.OpenVibe', icon: 'fa-network-wired' },
-- 'lookup.openvibe.tools': { name: 'OpenVibeLookup', icon: 'fa-magnifying-glass' },
-- 'myip.openvibe.tools': { name: 'OpenVibeMyIP', icon: 'fa-location-crosshairs' },
-- 'ip.openvibe.tools': { name: 'OpenVibeIP', icon: 'fa-at' },
-- 'geoip.openvibe.tools': { name: 'OpenVibeGeoIP', icon: 'fa-earth-americas' },
-- 'hostname.openvibe.tools': { name: 'OpenVibeHostname', icon: 'fa-server' },
-- 'isp.openvibe.tools': { name: 'OpenVibeISP', icon: 'fa-building' },
-- 'asn.openvibe.tools': { name: 'OpenVibeASN', icon: 'fa-diagram-project' },
-- 'ipv4.openvibe.tools': { name: 'OpenVibeIPv4', icon: 'fa-hashtag' },
-- 'ipv6.openvibe.tools': { name: 'OpenVibeIPv6', icon: 'fa-code' },
-- 'rdns.openvibe.tools': { name: 'OpenVibeReverseDNS',icon: 'fa-rotate-left' },
-- 'whois.openvibe.tools': { name: 'OpenVibeWhois', icon: 'fa-address-book' },
-- 'rdap.openvibe.tools': { name: 'OpenVibeRDAP', icon: 'fa-id-card' },
-- 'dns.openvibe.tools': { name: 'OpenVibeDNS', icon: 'fa-sitemap' },
-- 'dig.openvibe.tools': { name: 'OpenVibeDig', icon: 'fa-terminal' },
-- 'nslookup.openvibe.tools': { name: 'OpenVibeNSLookup', icon: 'fa-magnifying-glass-arrow-right' },
-- 'dnspropagation.openvibe.tools': { name: 'OpenVibeDNSPropagation', icon: 'fa-globe' },
-- 'mx.openvibe.tools': { name: 'OpenVibeMX', icon: 'fa-envelope' },
-- 'txt.openvibe.tools': { name: 'OpenVibeTXT', icon: 'fa-file-lines' },
-- 'ns.openvibe.tools': { name: 'OpenVibeNS', icon: 'fa-server' },
-- 'spf.openvibe.tools': { name: 'OpenVibeSPF', icon: 'fa-shield-halved' },
-- 'dkim.openvibe.tools': { name: 'OpenVibeDKIM', icon: 'fa-key' },
-- 'dmarc.openvibe.tools': { name: 'OpenVibeDMARC', icon: 'fa-user-shield' },
-- 'ping.openvibe.tools': { name: 'OpenVibePing', icon: 'fa-satellite-dish' },
-- 'traceroute.openvibe.tools': { name: 'OpenVibeTraceroute', icon: 'fa-route' },
-- 'mtr.openvibe.tools': { name: 'OpenVibeMTR', icon: 'fa-chart-line' },
-- 'port.openvibe.tools': { name: 'OpenVibePortCheck', icon: 'fa-door-open' },
-- 'headers.openvibe.tools': { name: 'OpenVibeHeaders', icon: 'fa-list' },
-- 'redirects.openvibe.tools': { name: 'OpenVibeRedirects', icon: 'fa-share' },
-- 'ssl.openvibe.tools': { name: 'OpenVibeSSL', icon: 'fa-lock' },
-- 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-download' },
-- 'httpstatus.openvibe.tools': { name: 'OpenVibeHTTPStatus', icon: 'fa-circle-check' },
-- 'latency.openvibe.tools': { name: 'OpenVibeLatency', icon: 'fa-gauge-high' },
-- // Dev.OpenVibe subdomains
-- 'dev.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
-- 'code.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
-- 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-code' },
-- 'yaml.openvibe.tools': { name: 'OpenVibeYAML', icon: 'fa-file-code' },
-- 'xml.openvibe.tools': { name: 'OpenVibeXML', icon: 'fa-file-code' },
-- 'csv.openvibe.tools': { name: 'OpenVibeCSV', icon: 'fa-table' },
-- 'sql.openvibe.tools': { name: 'OpenVibeSQL', icon: 'fa-database' },
-- 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
-- 'html.openvibe.tools': { name: 'OpenVibeHTML', icon: 'fa-file-code' },
-- 'base64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },
-- 'url.openvibe.tools': { name: 'OpenVibeURL', icon: 'fa-link' },
-- 'jwt.openvibe.tools': { name: 'OpenVibeJWT', icon: 'fa-key' },
-- 'uuid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },
-- 'hash.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },
-- 'hex.openvibe.tools': { name: 'OpenVibeHex', icon: 'fa-barcode' },
-- 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
-- 'timestamp.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
-- 'cron.openvibe.tools': { name: 'OpenVibeCron', icon: 'fa-calendar-check' },
-- 'beautify.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
-- 'minify.openvibe.tools': { name: 'OpenVibeMinify', icon: 'fa-compress' },
-- 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
-- 'regex.openvibe.tools': { name: 'OpenVibeRegex', icon: 'fa-magnifying-glass' },
-- 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },
-- 'lorem.openvibe.tools': { name: 'OpenVibeLorem', icon: 'fa-paragraph' },
-- 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },
-- 'webhook.openvibe.tools': { name: 'OpenVibeWebhook', icon: 'fa-satellite-dish' },
-- 'color.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
-- 'opengraph.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },
-- // Dev.OpenVibe aliases
-- 'build.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
-- 'debug.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
-- 'compare.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
-- 'format.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
-- 'prettier.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
-- 'md.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
-- 'unix.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
-- 'epoch.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
-- 'b64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },
-- 'guid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },
-- 'sha256.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },
-- 'entities.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
-- 'http.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },
-- 'og.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },
-- 'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
++ // ── Icons ────────────────────────────────────────────────
++ // The navbar draws its own icons. It used to borrow the page's Font Awesome, so on pages that do not load
++ // that font every menu row had an empty slot. Known names get an inline SVG; an unknown name still falls
++ // back to the page's icon font, so sites that pass their own Font Awesome icons keep working.
++ const NAV_ICONS = {
++ 'fa-user': '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>',
++ 'fa-user-secret': '<path d="M4 11h16M7.5 11l1.2-5h6.6l1.2 5"/><circle cx="9" cy="15" r="2"/><circle cx="15" cy="15" r="2"/><path d="M11 15h2"/>',
++ 'fa-clock-rotate-left': '<path d="M4.5 12a7.5 7.5 0 1 0 2.4-5.5L4.5 8.8M4.5 4.8v4h4M12 8v4.2l2.8 1.8"/>',
++ 'fa-bell': '<path d="M6.5 16.5h11l-1.2-2V11a4.3 4.3 0 0 0-8.6 0v3.5zM10.5 19a1.6 1.6 0 0 0 3 0"/>',
++ 'fa-palette': '<path d="M12 4.5a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.3h1.6a3.2 3.2 0 0 0 3.2-3.2C19.5 8 16.2 4.5 12 4.5z"/><circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',
++ 'fa-link': '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.700l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.700l1-1"/>',
++ 'fa-shield-halved': '<path d="M12 3.5l7 2.500v5.500c0 4.3-3 7.3-7 9-4-1.7-7-4.7-7-9V6zM12 3.500v17"/>',
++ 'fa-screwdriver-wrench': '<path d="M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z"/>',
++ 'fa-right-from-bracket': '<path d="M10 5H6.500A1.5 1.5 0 0 0 5 6.500v11A1.5 1.5 0 0 0 6.5 19H10M14 8l4 4-4 4M18 12H9.5"/>',
++ 'fa-plus': '<path d="M12 5.500v13M5.5 12h13"/>',
++ 'fa-paste': '<rect x="6" y="6" width="12" height="14" rx="1.8"/><path d="M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6"/>',
++ 'fa-house': '<path d="M4.5 11.5 12 5l7.5 6.500M6.5 10v8.500h11V10"/>',
++ 'fa-gauge-high': '<path d="M4.5 17a8 8 0 1 1 15 0M12 13l3.5-4"/><circle cx="12" cy="13.5" r="1.3"/>',
};
++ function navIcon(cls) {
++ const key = String(cls || '').split(/\s+/).find(c => NAV_ICONS[c]);
++ if (!key) return cls ? `<i class="fa-solid ${escapeAttr(cls)}"></i>` : '';
++ return `<svg class="ovnav-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${NAV_ICONS[key]}</svg>`;
++ }
// ─── Brand from hostname ───────────────────────────────────
// Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the
@@@ -759,7 +565,7 @@
function menuItemHTML(item) {
if (!item) return '';
if (item.sep) return '<div class="sep"></div>';
-- const icon = item.icon ? `<span class="icon"><i class="fa-solid ${escapeAttr(item.icon)}"></i></span>` : '<span class="icon"></span>';
++ const icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';
const cls = item.danger ? ' class="danger"' : '';
const id = item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : '';
if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}</a>`;
@@@ -1259,9 +1065,9 @@
nav.innerHTML = `
${brandHTML(brand)}
<div class="openvibe-navbar-links">
-- ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
++ ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}${escapeAttr(l.label)}</a>`).join('')}
${networkLinksHTML(links)}
-- ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
++ ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} Admin</a>` : ''}
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-right">
@@@ -1302,26 +1108,26 @@
</div>
`).join('')}
<div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">
-- <span style="width:24px;text-align:center"><i class="fa-solid fa-user-secret"></i></span>
++ <span style="width:24px;text-align:center">${navIcon('fa-user-secret')}</span>
<span>Switch to Anonymous</span>
</div>
<a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">
-- <span style="width:24px;text-align:center"><i class="fa-solid fa-plus"></i></span>
++ <span style="width:24px;text-align:center">${navIcon('fa-plus')}</span>
<span>Add another account</span>
</a>
</div>
<div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div>
<div class="openvibe-navbar-dropdown-menu">
${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}
-- <a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>
-- <a href="https://openvibe.network/my#history"><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History</a>
-- <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notifications</a>
-- <a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>
-- <a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>
-- ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}
++ <a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>
++ <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>
++ <a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>
++ <a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>
++ <a href="https://openvibe.network/my#linked"><span class="icon">${navIcon('fa-link')}</span> Linked Services</a>
++ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-screwdriver-wrench')}</span> Admin Panel</a>` : ''}
${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
<div class="sep"></div>
-- <button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
++ <button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button>
</div>
`;
nav.appendChild(dropdown);
ddiff --git a/vendor/openvibe-shared/notification-ui.js b/vendor/openvibe-shared/notification-ui.js
iindex 0b76b0f..5fb45ea 100644
---- a/vendor/openvibe-shared/notification-ui.js
++++ b/vendor/openvibe-shared/notification-ui.js
@@@ -131,9 +131,21 @@
.openvibe-nv-foot a { color: var(--accent-light, #60a5fa); text-decoration: none; }
.openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }
++ /* The panel hangs under whatever navbar the site has (its top is measured when it opens) and never
++ leaves the visible viewport: dvh instead of vh, room for the home indicator, list scrolls inside. */
++ .openvibe-notif-panel { top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));
++ max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px)); border-radius: 16px; }
++ .openvibe-notif-item .title .count { display: inline-block; margin-left: 4px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); vertical-align: 1px; }
++ /* Phones and narrow windows: a sheet from edge to edge under the bar, down to the bottom inset. */
++ @media (max-width: 640px) {
++ .openvibe-notif-panel { left: 8px; right: 8px; width: auto; bottom: calc(8px + env(safe-area-inset-bottom, 0px)); max-height: none; }
++ .openvibe-notif-panel [data-act] > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } /* icon-only actions, labels stay for screen readers */
++ .openvibe-notif-panel [data-act] { min-width: 36px; min-height: 36px; justify-content: center; }
++ .openvibe-notif-item { padding-top: 10px; padding-bottom: 10px; }
++ }
++ @media (max-height: 520px) { .openvibe-notif-item .msg { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } }
@media (max-width: 460px) {
.openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
-- .openvibe-notif-panel { right: 6px; left: 6px; width: auto; top: 56px; max-height: calc(100vh - 66px); }
}
`;
document.head.appendChild(style);
@@@ -372,9 +384,11 @@
const item = e.target.closest('.openvibe-notif-item');
if (!item) return;
const id = item.dataset.id;
-- if (dismissBtn) { e.stopPropagation(); dismiss(v, id); return; }
++ const ids = (item.dataset.ids || id).split(',').filter(Boolean);
++ if (dismissBtn) { e.stopPropagation(); ids.forEach(x => dismiss(v, x)); return; }
const n = v.items.find(x => String(x.id) === id);
-- if (n && !isRead(n)) markRead([id]);
++ const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });
++ if (unreadIds.length) markRead(unreadIds);
const u = urlOf(n);
if (u) navigateTo(u);
});
@@@ -385,14 +399,21 @@
function renderItems(v, { append = false } = {}) {
const list = v.root.querySelector('[data-role="list"]');
-- const html = v.items.map(n => {
++ // The same event repeated back to back ("X is live!" six times) reads as one row with a count.
++ const groups = [];
++ for (const n of v.items) {
++ const key = [n.title, n.message || '', n.service || '', n.category || ''].join('\u0001');
++ const g = groups[groups.length - 1];
++ if (g && g.key === key) { g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true; } else groups.push({ key, n, ids: [String(n.id)], unread: !isRead(n) });
++ }
++ const html = groups.map(({ n, ids, unread }) => {
try {
const cat = CATEGORY_LABELS[n.category] || n.category || '';
const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
-- return `<div class="openvibe-notif-item ${isRead(n) ? '' : 'unread'}" data-id="${esc(n.id)}" title="${esc(urlOf(n) ? 'Open' : '')}">
++ return `<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" title="${esc(urlOf(n) ? 'Open' : '')}">
<span class="icon">${iconHtml(n)}</span>
<div class="content">
-- <div class="title">${esc(n.title)}</div>
++ <div class="title">${esc(n.title)}${ids.length > 1 ? ` <span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>
${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}
<div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>
</div>
@@@ -546,6 +567,13 @@
function togglePanel() {
if (!_panelEl) createPanel();
const opening = !_panelEl.classList.contains('open');
++ if (opening) {
++ // Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).
++ const bar = _bellEl && _bellEl.closest('nav, header, .navbar');
++ const bottom = bar ? bar.getBoundingClientRect().bottom : (_bellEl ? _bellEl.getBoundingClientRect().bottom : 56);
++ _panelEl.style.setProperty('--ovn-top', Math.max(8, Math.round(bottom) + 6) + 'px');
++ if (!_panelEl.__esc) { _panelEl.__esc = (e) => { if (e.key === 'Escape' && _panelEl.classList.contains('open')) togglePanel(); }; document.addEventListener('keydown', _panelEl.__esc); }
++ }
_panelEl.classList.toggle('open', opening);
_bellEl?.classList.toggle('open', opening);
if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }
ddiff --git a/vendor/openvibe-shared/ov-icons.js b/vendor/openvibe-shared/ov-icons.js
iindex d6041f2..b6de4c1 100644
---- a/vendor/openvibe-shared/ov-icons.js
++++ b/vendor/openvibe-shared/ov-icons.js
@@@ -129,6 +129,24 @@
return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><circle class="ovi-prog" cx="24" cy="24" r="20"/><g transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)"><g class="ovi-glyph">${def.glyph}</g></g></svg>`;
}
++ // ── Sprite mode (server-rendered lists) ──────────────────────────────────────────────
++ // A page that shows 170 icons should not ship 170 copies of the same ring and glyph. sprite(names) emits each
++ // icon once as a <symbol>; use(name) references it. Colours come from currentColor and inherited custom
++ // properties, so a referenced icon is themed exactly like an inline one. Static by design (no comet, no
++ // progress): pages use it for listings and keep the full inline icon where state or motion matters.
++ function sprite(names) {
++ const seen = new Set();
++ const symbols = (names || []).map(resolve).filter(n => !seen.has(n) && seen.add(n)).map((key) => {
++ const def = registry[key]; const off = def.offset || OFFSETS[key] || [0, 0];
++ return `<symbol id="ovi-${key}" viewBox="0 0 48 48"><circle cx="24" cy="24" r="21" fill="currentColor" opacity=".1"/><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="2.4" opacity=".3"/><g transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--ovi-glyph,var(--text-primary,#e6edf7))">${def.glyph}</g></symbol>`;
++ }).join('');
++ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">${symbols}</svg>`;
++ }
++ function use(name, size) {
++ const key = resolve(name);
++ return `<span class="ov-icon" data-icon="${key}" data-ovi="${key}" data-fx="none" style="--ovi-size:${parseInt(size, 10) || 32}px" aria-hidden="true"><svg viewBox="0 0 48 48" focusable="false"><use href="#ovi-${key}"/></svg></span>`;
++ }
++
function applyProgress(el) {
const raw = el.getAttribute('data-progress');
const prog = el.querySelector('.ovi-prog');
@@@ -168,7 +186,7 @@
if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });
}
-- const api = { names: () => Object.keys(registry), register, mount, set, svg, resolve, CSS };
++ const api = { names: () => Object.keys(registry), register, mount, set, svg, sprite, use, resolve, CSS };
root.OpenVibeIcons = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document === 'undefined') return;
ddiff --git a/vendor/openvibe-shared/package.json b/vendor/openvibe-shared/package.json
iindex c6c8b79..01bacf2 100644
---- a/vendor/openvibe-shared/package.json
++++ b/vendor/openvibe-shared/package.json
@@@ -15,6 +15,8 @@
"./seo": "./seo.js",
"./icons": "./ov-icons.js",
"./legal": "./legal.js",
++ "./footer": "./footer.js",
++ "./chrome-ssr": "./chrome-ssr.js",
"./app-icon": "./app-icon.js"
},
"license": "MIT",