/** * openvibe-shared/ov-icons.js — the OpenVibe animated icon family. * * * * * One construction for every icon so the network reads as one brand: the OV ring (with its comet) * around a glyph drawn in the theme accent. The ring is also a progress meter (data-progress 0–1) * and a status light (data-state idle|busy|ok|error), so the same icon that names a tool can show * what the tool is doing. Glyphs are 24×24 stroke paths centred in the ring. * * OpenVibeIcons.names() every registered icon * OpenVibeIcons.register(name, { glyph, fx?, accent? }) * OpenVibeIcons.mount(root?) (re)scan; new nodes are picked up automatically * OpenVibeIcons.set(el, { progress, state, icon }) * OpenVibeIcons.svg(name, size) markup string, for server-side / templates * * Nothing moves at rest (the owner, 10-31 and 11-07: "things only spin if they're active"; the comet spun forever on * every icon until 4.128.0). The comet, the pulse and the bounce run only while data-state="busy"; at rest an icon is * its ring and glyph, with a small lift on hover where a pointer hovers. "draw" plays once when an icon appears. * Effects (data-fx, what busy looks like): orbit (default) · pulse · draw · bounce · none. Motion is opacity/stroke inside the * SVG and transform on the element only: no CSS transform ever touches an element inside the SVG * (transform-box/transform-origin on SVG children left glyphs and whole icons unpainted on phones while * scrolling), and hover effects apply only where a pointer can hover, so a thumb scrolling over a link * starts nothing. Calm slows the loops; only motion off stops them. Unknown names fall back to the OV mark "V". */ (function (root) { 'use strict'; if (root.OpenVibeIcons) return; const P = (d) => ``; // Glyphs: stroke-only, 24×24 box, designed to sit inside the ring. const GLYPHS = { ov: '', network: '', live: '', tools: '', media: '', games: '', community: '', chat: '', codes: '', bot: '', code: '', blog: '', wiki: '', news: '', reviews: '', tips: '', vip: '', trade: '', host: '', deals: '', coupons: '' , stream: '', youtube: '' + P('M10.5 9.8v4.4l3.8-2.2z'), video: '' + P('M10.5 9.8v4.4l3.8-2.2z'), download: '', upload: '', image: '', audio: '', pdf: '', docs: '', text: '', logo: '', json: '', dns: '', ip: '', ssl: '', ping: P('M3.5 12h4l2-5 3.5 10 2.5-7 1.5 2h3.5'), whois: '', search: '', map: '', food: '', // Page furniture the product homes name (2026-10-10, 2.21.1). gauge: '', link: '', shield: '', plug: '', // Products (2026-10-10): the sites that had only the "V"; a solid twin where its glyph has one (RING_SOLIDS, scripts/build-navbar-icons.js). work: '', rent: '', help: '', quest: '', inventory: '', play: '', drive: '', services: '', space: '', watch: '' + P('M3.5 12s3-5.5 8.5-5.5 8.5 5.5 8.5 5.5-3 5.5-8.5 5.5S3.5 12 3.5 12z'), actor: '', events: '', ai: '', run: '' + P('M7.5 10l2.5 2-2.5 2M12 14.5h4'), billing: '', feeds: '', // Their own glyphs (11-07; until then each borrowed a neighbour's: Cam showed Video's, Casino and Surf the gamepad, // Fun Quest's trophy), so every site in the menu reads at a glance. cam: '' + P('M8.6 7.5 10 5h4l1.4 2.5') + '', casino: '', surf: '', fun: '', meme: '', tours: '', vision: P('M3.5 12S6.5 6.5 12 6.5 20.5 12 20.5 12 17.5 17.5 12 17.5 3.5 12 3.5 12z') + '', shoes: '', today: '', report: '', design: '', domains: '', exchange: '', bench: '', pics: '', homes: '', website: '', zone: '', fund: '', paste: '', account: '', // 4.182.0: the products that had no mark of their own drew the fallback "V" in every site grid (Live's and // Network's homes showed five of them), and so did a few things sites name as marks (a shoe, a heart, a // calendar, a camera). Each has its glyph now. computer: '', mail: '', lock: '', git: '', barcode: '', party: '', studio: '', home: '', heart: '', calendar: '', camera: '', shoe: '', bell: '', history: '', theme: '', check: '', error: '', clip: '' , }; // [dx, dy] in glyph units that move each glyph's drawn bounds onto the centre of the ring. Generated. const OFFSETS = {"ov":[0,-0.05],"logo":[0.15,0.05],"computer":[0,-0.15],"mail":[0,-0.05],"lock":[0.05,0.2],"git":[-0.35,-0.05],"barcode":[0.1,0],"party":[0,-0.1],"studio":[0.05,0],"home":[0,0.35],"heart":[0,-0.38],"calendar":[0,-0.05],"camera":[0,0.25],"shoe":[-0.1,0.35]}; const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell', 'media-hub': 'drive', mediahub: 'drive', sources: 'feeds', agent: 'actor', jobs: 'work', homes: 'homes', page: 'docs', db: 'zone', database: 'zone', location: 'ip', gamepad: 'games', robot: 'bot', deploy: 'upload', copy: 'paste', activity: 'ping', // The 2026-10-28 rooms, on the nearest glyph until each has its own (rooms.js): never the fallback mark. domains: 'dns', design: 'theme', today: 'history', report: 'gauge', surf: 'games', casino: 'games', fun: 'quest', meme: 'image', exchange: 'trade', cam: 'video', tours: 'map', vision: 'ai', shoes: 'deals', // OpenVibe Connect (Website Connect v0, 2026-11-02): a port on your machine, on the web. connect: 'plug', // 4.182.0: other names the same products go by. my: 'account', locker: 'lock', email: 'mail', restream: 'stream', node: 'link', desktop: 'computer' }; const registry = Object.create(null); for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g }; /** The app icon's own rules (design rule 142). A page drawn on the server puts these in its stylesheet once * (OpenVibeIcons.APP_CSS) and its appHtml() icons are whole before any script runs. */ const APP_CSS = ` .ov-appicon{--ova-h:215;--ova-size:56px;position:relative;display:inline-block;flex:none;width:var(--ova-size);height:var(--ova-size);border-radius:30%;overflow:hidden;vertical-align:middle;background:linear-gradient(150deg,hsl(var(--ova-h) 72% 58% / .36),hsl(var(--ova-h) 70% 38% / .2));box-shadow:inset 0 0 0 1px hsl(var(--ova-h) 70% 64% / .3)} .ov-appicon>.ov-icon{position:absolute;inset:0;width:100%;height:100%;display:block;--ovi-glyph:hsl(var(--ova-h) 92% 84%);color:hsl(var(--ova-h) 92% 84%)} .ov-appicon>.ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;margin:0;transform:none!important} .ov-appicon .ovi-ring,.ov-appicon .ovi-bg,.ov-appicon .ovi-comet,.ov-appicon .ovi-prog{display:none} :root[data-theme="light"] .ov-appicon>.ov-icon{--ovi-glyph:hsl(var(--ova-h) 70% 30%);color:hsl(var(--ova-h) 70% 30%)} .ov-appicon[data-size="20"]{--ova-size:20px} .ov-appicon[data-size="26"]{--ova-size:26px} .ov-appicon[data-size="32"]{--ova-size:32px} .ov-appicon[data-size="44"]{--ova-size:44px} .ov-appicon[data-size="56"]{--ova-size:56px} .ov-appicon[data-size="64"]{--ova-size:64px}`; const CSS = `${APP_CSS} .ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative} .ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)} .ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s} .ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26} .ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;display:none} .ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0} .ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s} .ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:fill .2s} .ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none} .ov-icon[style*="--ovi-size:2"] .ovi-glyph,.ov-icon[data-size^="2"] .ovi-glyph{stroke-width:2.3} .ov-icon[style*="--ovi-size:2"] .ovi-ring,.ov-icon[data-size^="2"] .ovi-ring{stroke-width:3;opacity:.34} @media (hover:hover){.ov-icon:hover svg,a:hover>.ov-icon svg,button:hover>.ov-icon svg{transform:scale(1.06)}.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}} .ov-icon[data-fx=none] .ovi-comet{display:none} .ov-icon[data-fx=pulse] .ovi-comet{display:none} .ov-icon[data-fx=pulse][data-state=busy] .ovi-bg{animation:oviPulse 2s ease-in-out infinite} .ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both} .ov-icon[data-fx=bounce][data-state=busy] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite} .ov-icon[data-progress] .ovi-prog{opacity:1} .ov-icon[data-progress] .ovi-comet{opacity:0} .ov-icon[data-state=busy] .ovi-comet{display:inline;animation:oviSpin 1.1s linear infinite;opacity:1;stroke-dasharray:40 85.66} .ov-icon[data-state=busy][data-fx=none] .ovi-comet,.ov-icon[data-state=busy][data-fx=pulse] .ovi-comet{display:none} .ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35} .ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)} .ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)} .ov-icon[data-state=ok] .ovi-comet,.ov-icon[data-state=error] .ovi-comet{display:none} .ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9} .ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)} .ov-icon[data-state=error] svg{animation:oviShake .4s ease} @keyframes oviSpin{from{stroke-dashoffset:0}to{stroke-dashoffset:-125.66}} @keyframes oviPulse{0%,100%{opacity:.08}50%{opacity:.24}} @keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}} @keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}} @keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}} @keyframes oviShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6%)}75%{transform:translateX(6%)}} html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`; // BEGIN generated ring solids (scripts/build-navbar-icons.js from glyphs.js) // The solid drawing of the glyph that stands for each product (24-unit grid, openvibe-shared/glyphs). const SOLID = { // network: the glyph nodes network: '', // live: the glyph live live: '', // tools: the glyph wrench tools: '', // media: the glyph film media: '', // games: the glyph game games: '', // community: the glyph members community: '', // chat: the glyph chat chat: '', // domains: the glyph globe domains: '', // bench: the glyph flag bench: '', }; // END generated ring solids // A product's solid: the solid drawing of the glyph that stands for it, on the same 24-unit grid as the line // drawings (4.231.0). Its .ln parts are lines inside the solid, drawn as strokes. // Names without a solid keep their line drawing (the brand "V", YouTube, logo, ...). const LN = ' fill="none" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--ovi-glyph,var(--text-primary,#e6edf7))"'; function glyphMarkup(key, def, forSprite) { const solid = SOLID[key]; if (!solid) return forSprite ? def.glyph : `${def.glyph}`; const paint = forSprite ? ' fill="currentColor" stroke="none" style="fill:var(--ovi-glyph,var(--text-primary,#e6edf7))"' : ''; return `${solid.replace(/ class="ln"/g, ` class="ln"${LN}`)}`; } function resolve(name) { const n = String(name || 'ov').toLowerCase(); return registry[n] ? n : (registry[ALIASES[n]] ? ALIASES[n] : 'ov'); } /** Markup for one icon (usable server-side: no DOM needed). Glyph is scaled 24→22 and centred in the 48 box. */ function svg(name, size) { const key = resolve(name); const def = registry[key]; const off = def.offset || OFFSETS[key] || [0, 0]; // measured optical centring, see scripts/measure-icons.js const s = size ? ` width="${size}" height="${size}"` : ''; return ``; } // ── 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 ; 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 `${glyphMarkup(key, def, true)}`; }).join(''); return ``; } function use(name, size) { const key = resolve(name); return ``; } function applyProgress(el) { const raw = el.getAttribute('data-progress'); const prog = el.querySelector('.ovi-prog'); if (!prog) return; if (raw == null || raw === '') { prog.style.strokeDashoffset = ''; return; } const p = Math.max(0, Math.min(1, parseFloat(raw) || 0)); prog.style.strokeDashoffset = String(125.7 * (1 - p)); } function mountOne(el) { const name = resolve(el.getAttribute('data-icon')); if (el.getAttribute('data-ovi') === name) { applyProgress(el); return; } el.setAttribute('data-ovi', name); const size = parseInt(el.getAttribute('data-size'), 10); if (size) el.style.setProperty('--ovi-size', size + 'px'); const def = registry[name]; if (def.accent) el.style.setProperty('--ovi-accent', def.accent); if (def.fx && !el.hasAttribute('data-fx')) el.setAttribute('data-fx', def.fx); el.innerHTML = svg(name); if (!el.hasAttribute('role')) el.setAttribute('aria-hidden', 'true'); applyProgress(el); } function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-appicon[data-icon]').forEach(fillApp); (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); } /** A site's (or an app's) steady hue: the same on every page and device (the menu's and Home's own). */ function hue(id) { let x = 7; const t = String(id || 'ov'); let i = 0; while (i < t.length) { x = (x * 31 + t.charCodeAt(i)) % 360; i++; } return x; } /** 4.225.0 (design rule 142): an app icon, the same as the menu's and Home's: the app's own hue on a rounded square, * its glyph at 58% of it. In markup, ; or * OpenVibeIcons.app('live', { size: 64, label: 'Live' }). Without a label it is decoration (aria-hidden). */ function fillApp(el) { const name = String(el.getAttribute('data-icon') || 'ov'); if (el.getAttribute('data-ova') === name) return; el.setAttribute('data-ova', name); el.style.setProperty('--ova-h', String(el.hasAttribute('data-hue') ? Number(el.getAttribute('data-hue')) % 360 : hue(name))); const size = parseInt(el.getAttribute('data-size'), 10); if (size) el.style.setProperty('--ova-size', `${size}px`); const label = el.getAttribute('data-label'); if (label) { el.setAttribute('role', 'img'); el.setAttribute('aria-label', label); } else el.setAttribute('aria-hidden', 'true'); el.textContent = ''; const g = document.createElement('span'); g.className = 'ov-icon'; g.setAttribute('data-icon', name); g.setAttribute('data-fx', 'none'); el.appendChild(g); mountOne(g); } /** 4.227.0: the same app icon as markup, for a page drawn on the server (no DOM needed): its hue and size inline and * its glyph already drawn, so ov-icons.js finds nothing to fill and nothing moves when it arrives. */ function appHtml(site, { size = 56, label = '', hue: h = null } = {}) { const name = String(site || 'ov'); const key = resolve(name); const n = Math.min(Math.max(parseInt(size, 10) || 56, 12), 256); const e = (t) => String(t).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); const given = h != null && h !== '' && Number.isFinite(Number(h)); const hh = given ? Math.abs(Math.round(Number(h))) % 360 : hue(name); const a11y = label ? ` data-label="${e(label)}" role="img" aria-label="${e(label)}"` : ' aria-hidden="true"'; return `` + ``; } function app(site, { size = 56, label = '', hue: h = null } = {}) { const el = document.createElement('span'); el.className = 'ov-appicon'; el.setAttribute('data-icon', String(site || 'ov')); if (size) el.setAttribute('data-size', String(size)); if (label) el.setAttribute('data-label', String(label)); if (h != null) el.setAttribute('data-hue', String(h)); fillApp(el); return el; } function set(el, patch) { if (!el || !patch) return; if (patch.icon) el.setAttribute('data-icon', patch.icon); if ('state' in patch) { if (patch.state) el.setAttribute('data-state', patch.state); else el.removeAttribute('data-state'); } if ('progress' in patch) { if (patch.progress == null) el.removeAttribute('data-progress'); else el.setAttribute('data-progress', String(patch.progress)); } mountOne(el); } function register(name, def) { if (!name || !def || !def.glyph) return; registry[String(name).toLowerCase()] = { glyph: def.glyph, fx: def.fx || null, accent: def.accent || null, offset: Array.isArray(def.offset) ? def.offset : null }; 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, sprite, use, resolve, app, appHtml, hue, CSS, APP_CSS }; root.OpenVibeIcons = api; if (typeof module !== 'undefined' && module.exports) module.exports = api; if (typeof document === 'undefined') return; function init() { if (!document.getElementById('ov-icons-css')) { const st = document.createElement('style'); st.id = 'ov-icons-css'; st.textContent = CSS; document.head.appendChild(st); } mount(); try { new MutationObserver((muts) => { for (const m of muts) { if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-appicon')) { m.target.removeAttribute('data-ova'); fillApp(m.target); continue; } if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; } m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-appicon') && n.hasAttribute('data-icon')) { fillApp(n); return; } if (n.classList.contains('ov-icon')) { mountOne(n); return; } if (n.querySelector) { n.querySelectorAll('.ov-appicon[data-icon]').forEach(fillApp); n.querySelectorAll('.ov-icon').forEach(mountOne); } }); } }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] }); } catch { /* */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })(typeof window !== 'undefined' ? window : globalThis);