/**
* 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 `${glyphMarkup(key, def, false)} `;
}
// ── 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 `${symbols} `;
}
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 ``
+ `${svg(key)} `;
}
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);