openvibers/openvibe.shared Commits
4.16.0: one balance chip on a phone, Across OpenVibe with site marks, icons that don't pop in, tabs that update themselves (#137)
Changes
@@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.16.0 — 2026-10-27++++- **Balance chips fold into one on a phone** (`navbar.js`; owner, 2026-10-27: "the currencies are taking up all the++ space … compact the two currencies into a single one when on smaller mobile resolutions … so you can still swap++ between sites on mobile").++ - With two or more chips at 560 px and narrower, the bar shows one chip with each icon and a short value ("161k · 400").++ - A tap lists each in full; a row does what its chip does (a chip may now carry a `label`).++ - `setChip` keeps both up to date. A site can show the site switcher next to its logo again (Live hid it to make room).++- **"Across OpenVibe" in the menu is a section of its own:**++ - a tinted block with a header (the OV mark) and **All sites** (it opens the launcher);++ - the other sites two to a row, each with its mark (up to eight).++- **Icons no longer pop in.**++ - `ov-icons.js` loads once when the page settles (`requestIdleCallback`), and at once on intent (a pointer over the++ launcher, a touch, focus), instead of the first time the launcher, menu or drawer opened.++ - Every icon slot there has its size and a faint ring until it is drawn, so nothing moves when the glyphs arrive.++- **An open tab stays on the latest version** (`release-watch.js`; owner, 2026-10-27: "make sure people are on the++ latest version … if the client needs to update or something, it should just be self-aware or intelligently do that").++ - A release that only prompted ("Update ready · Reload") used to wait for the button forever. Now it applies itself++ while nobody is looking: after the tab has been hidden for 30 s, unless something is protected (typing, a dirty form,++ playing media, a live camera or mic, `[data-ov-protected]`, `OVProtected()`).++ - Any reload comes back at the same scroll position, for the same address, for ten minutes.++ - A site can opt out with `OVReleaseConfig.autoApply = false`. Style and content releases still apply in place, and++ required ones reload when safe, as before.++## 4.15.2 — 2026-10-27- **Showcase sections no longer fade in** (`showcase.js`, `showcase.css`). The scroll reveal animated opacity from@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.15.2 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.16.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.15.2",++ "version": "4.16.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -110,6 +110,39 @@.ovnav-drawer .ud-label { padding: 10px 12px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }.ovnav-drawer-net:empty { display: none; } .ovnav-drawer-net { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin-top: 4px; }.ovnav-drawer-net a.ovnav-net { display: flex !important; }++ /* "Across OpenVibe" in the menu (owner, 2026-10-27: icons, and set apart from the site's own links): a tinted++ section, its own header with "All sites", a grid of the other sites, each with its mark. */++ .ovnav-drawer-net:not(:empty) { margin: 8px 0 0; padding: 6px; border: 0; border-radius: 13px; background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); }++ .ovnav-drawer-net .ovnav-net-head { display: flex; align-items: center; gap: 8px; padding: 6px 6px 8px; }++ .ovnav-drawer-net .ovnav-net-head .ud-label { padding: 0; flex: 1; }++ .ovnav-drawer-net .ovnav-net-all { all: unset; cursor: pointer; font-size: 12px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); padding: 3px 6px; border-radius: 7px; }++ .ovnav-drawer-net .ovnav-net-all:hover, .ovnav-drawer-net .ovnav-net-all:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .ovnav-drawer-net .ovnav-net-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; }++ .ovnav-drawer .ovnav-drawer-net a.ovnav-net { gap: 9px; padding: 8px; font-size: 13.5px; min-width: 0; }++ .ovnav-drawer .ovnav-drawer-net a.ovnav-net > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ /* Icons have their size and a faint ring before ov-icons.js draws them: nothing moves or pops when they arrive. */++ :is(.ovnav-launcher, .ovnav-drawer) .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px; border-radius: 50%; }++ .ovnav-launcher .ovl-app > .ov-icon { width: 28px; height: 28px; }++ .ovnav-drawer .ov-icon[data-size="22"] { width: 22px; height: 22px; }++ :is(.ovnav-launcher, .ovnav-drawer) .ov-icon:empty { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }++ /* Several balance chips fold into one on a phone (owner, 2026-10-27: the two currencies pushed out the site++ switcher next to the logo); a tap lists each in full. */++ .openvibe-navbar .ovnav-chip--all { display: none; gap: 8px; padding: 0 10px; --chip-c: var(--text-secondary, #a8b3c4); }++ .ovnav-chip-mini { display: inline-flex; align-items: center; gap: 4px; color: var(--chip-c, inherit); }++ .ovnav-chip-mini[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip-mini[data-tone="green"] { --chip-c: #4ade80; }++ .ovnav-chip-mini .ovnav-ic { width: 13px; height: 13px; }++ .ovnav-chips-pop { position: absolute; top: calc(100% + 6px); right: 8px; z-index: 1002; min-width: 220px; padding: 6px; border-radius: 14px; display: none; flex-direction: column; gap: 2px;++ background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 40px rgba(0,0,0,.45); }++ .ovnav-chips-pop.open { display: flex; animation: openvibe-slide-down .18s ease; }++ .ovnav-chips-pop button { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; font-size: 14px; color: var(--text-primary, #e6edf7); }++ .ovnav-chips-pop button:hover, .ovnav-chips-pop button:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .ovnav-chips-pop button .ovnav-ic { color: var(--chip-c, inherit); width: 16px; height: 16px; }++ .ovnav-chips-pop button[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chips-pop button[data-tone="green"] { --chip-c: #4ade80; }++ .ovnav-chips-pop b { margin-left: auto; font-variant-numeric: tabular-nums; }++ @media (max-width: 560px) {++ .openvibe-navbar-right.has-chips-all .ovnav-chip[data-chip-id] { display: none; }++ .openvibe-navbar-right.has-chips-all .ovnav-chip--all { display: inline-flex; }++ }@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; box-sizing: border-box; width: min(760px, 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: 0 12px 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); }@@@ -652,6 +685,7 @@function bindLauncher(nav) {const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;++ for (const ev of ['pointerenter', 'touchstart', 'focus']) btn.addEventListener(ev, () => loadOvIcons(), { once: true, passive: true });let panel = null;const tile = (it, cls, here) => `<a class="${cls}${here ? ' is-here' : ''}" href="${escapeAttr(it.go || it.url)}"${here ? ' aria-current="page"' : ''}${it.desc ? ` title="${escapeAttr(it.desc)}"` : ''}><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-app' ? 28 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;const siteItems = (frame) => (frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline.replace(/\.\s*$/, ''), icon: n.icon || n.id, url: n.url, group: n.group })) : LAUNCHER_SITES);@@@ -699,7 +733,7 @@bindDisplayControls(panel.querySelector('.ovl-display'));nav.appendChild(panel);regPanel(panel, 'launcher', close);-- if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); }++ loadOvIcons();let cat = null; paint(null, '');const input = panel.querySelector('.ovl-q');input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));@@@ -736,6 +770,63 @@function chipsHTML(chips, cls) {return (Array.isArray(chips) ? chips : []).filter(Boolean).map(c => `<button type="button" class="${cls || 'ovnav-chip'}" data-chip-id="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}${c.title ? ` title="${escapeAttr(c.title)}"` : ''}${c.hidden ? ' hidden' : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-v"${c.valueId ? ` id="${escapeAttr((cls ? 'menu-' : '') + c.valueId)}"` : ''}>${escapeAttr(c.value == null ? '' : c.value)}</span></button>`).join('');}++ /** "160,774" → "161k", for the folded chip; anything that is not a number stays as it is. */++ function shortValue(v) {++ const s = v == null ? '' : String(v); const n = Number(s.replace(/[,\s]/g, ''));++ if (!s || !isFinite(n)) return s;++ const a = Math.abs(n);++ if (a >= 1e6) return `${(n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, '')}M`;++ if (a >= 1e4) return `${Math.round(n / 1e3)}k`;++ if (a >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, '')}k`;++ return String(Math.round(n));++ }++ const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0];++ /** The chips folded into one (shown instead of them on a phone): each chip's icon and short value. */++ function chipsAllHTML(chips) {++ const list = (Array.isArray(chips) ? chips : []).filter((c) => c && !c.hidden);++ if (list.length < 2) return '';++ return `<button type="button" class="ovnav-chip ovnav-chip--all" id="openvibe-chips-all" aria-haspopup="true" aria-expanded="false" aria-label="Your balances: ${escapeAttr(list.map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', '))}">${list.map((c) => `<span class="ovnav-chip-mini" data-chip-mini="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-mv">${escapeAttr(shortValue(c.value))}</span></span>`).join('')}</button>`;++ }++ function bindChipsAll(nav) {++ const btn = nav.querySelector('#openvibe-chips-all'); if (!btn || typeof btn.addEventListener !== 'function') return;++ const right = typeof btn.closest === 'function' ? btn.closest('.openvibe-navbar-right') : null;++ if (right && right.classList) right.classList.add('has-chips-all');++ let pop = null;++ const close = () => { if (pop) pop.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };++ btn.addEventListener('click', (e) => {++ e.stopPropagation();++ if (pop && pop.classList.contains('open')) return close();++ if (!pop) { pop = document.createElement('div'); pop.className = 'ovnav-chips-pop'; pop.setAttribute('role', 'menu'); nav.appendChild(pop); regPanel(pop, 'chips', close); }++ const list = (_config.chips || []).filter((c) => c && !c.hidden);++ pop.innerHTML = list.map((c) => `<button type="button" role="menuitem" data-chip-row="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}>${c.icon ? navIcon(c.icon) : ''}<span>${escapeAttr(chipLabel(c))}</span><b>${escapeAttr(c.value == null ? '' : c.value)}</b></button>`).join('');++ pop.querySelectorAll('[data-chip-row]').forEach((row) => row.addEventListener('click', (ev) => {++ ev.stopPropagation(); close();++ const c = list.find((x) => x.id === row.getAttribute('data-chip-row'));++ if (c && typeof c.onClick === 'function') c.onClick(ev);++ }));++ pop.classList.add('open'); btn.setAttribute('aria-expanded', 'true');++ const first = pop.querySelector('button'); if (first) first.focus();++ });++ }++ /**++ * ov-icons.js draws every .ov-icon in the launcher, the menu and the drawer. It used to load the first time one of them++ * opened, so their icons popped in a moment later and the rows moved (owner, 2026-10-27). Now it loads once, quietly,++ * after the page settles, and at once on intent (a pointer over the launcher, a touch, focus); the slots have their++ * size meanwhile.++ */++ let _iconsQueued = false;++ function loadOvIcons(scope) {++ if (root.OpenVibeIcons) { if (scope && root.OpenVibeIcons.mount) root.OpenVibeIcons.mount(scope); return; }++ if (document.getElementById('ov-icons-loader')) return;++ try { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }++ }++ function queueIcons() {++ if (_iconsQueued || root.OpenVibeIcons) return;++ _iconsQueued = true;++ const go = () => loadOvIcons();++ if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 4000 });++ else { const t = setTimeout(go, 2500); if (t && typeof t.unref === 'function') t.unref(); }++ }function bindChips(scope) {(_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {if (!c || typeof c.onClick !== 'function') return;@@@ -774,7 +865,12 @@const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd'));nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);if (burger && drawer) {-- burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); });++ burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer); });++ drawer.addEventListener('click', (e) => {++ const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return;++ e.preventDefault(); e.stopPropagation(); closeDrawer();++ const lb = nav.querySelector('#openvibe-launcher-btn'); if (lb) lb.click();++ });document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); }, onDocument());regPanel(drawer, 'nav-drawer', closeDrawer);}@@@ -849,10 +945,14 @@if (!frame) return '';const here = currentHost().toLowerCase();const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));-- const max = inDrawer ? 6 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : 4);++ const max = inDrawer ? 8 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : 4);const pick = frame.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);if (!pick.length) return '';-- return (inDrawer ? '<div class="ud-label">Across OpenVibe</div>' : `<span class="ovnav-sep" aria-hidden="true"></span>`) + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');++ if (inDrawer) {++ return `<div class="ovnav-net-head"><span class="ov-icon" data-icon="ov" data-size="22" data-fx="none"></span><span class="ud-label">Across OpenVibe</span>${_config.launcher === false ? '' : '<button type="button" class="ovnav-net-all" data-open-launcher>All sites</button>'}</div>`++ + `<div class="ovnav-net-grid">${pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}"><span class="ov-icon" data-icon="${escapeAttr(n.icon || n.id)}" data-size="22" data-fx="none"></span><span>${escapeAttr(n.name)}</span></a>`).join('')}</div>`;++ }++ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');}/** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */@@@ -1488,7 +1588,7 @@</div><div class="openvibe-navbar-spacer"></div><div class="openvibe-navbar-right">-- ${u ? chipsHTML(_config.chips) : ''}++ ${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}<div id="openvibe-bell-mount"></div>${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}@@@ -1499,6 +1599,8 @@if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));bindLinks(nav, links);bindChips(nav);++ bindChipsAll(nav);++ queueIcons();bindLauncher(nav);countView();// Pages that wire the bell themselves run right after init(); give them the first go.@@@ -1585,7 +1687,7 @@avatarBtn.addEventListener('click', () => {dropdown.classList.toggle('open');const ask = dropdown.querySelector('[data-ov-ask]'); if (ask) ask.href = askVibeHref(sel ?? selectedText()); sel = null;-- if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; if (root.OpenVibeIcons) root.OpenVibeIcons.mount(dropdown); else if (!document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } }++ if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; loadOvIcons(dropdown); }});// Close on outside click@@@ -1742,6 +1844,10 @@const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; });++ document.querySelectorAll(`[data-chip-mini="${id}"] .ovnav-chip-mv`).forEach(el => { el.textContent = shortValue(value); });++ document.querySelectorAll(`[data-chip-row="${id}"] b`).forEach(el => { el.textContent = value == null ? '' : String(value); });++ const all = document.getElementById('openvibe-chips-all');++ if (all) all.setAttribute('aria-label', `Your balances: ${(_config.chips || []).filter((c) => c && !c.hidden).map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', ')}`);},/** Patch a top-level or dropdown link by id: updateLink('broadcast', { hidden: false, dot: true, label }). */updateLink(id, patch) {@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.15.2",++ "version": "4.16.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.15.2",++ "version": "4.16.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.15.2",++ "version": "4.16.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -6,7 +6,8 @@* min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no* focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live* camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).-- * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta++ * An optional release applies itself after 30 s hidden with nothing protected, at the same scroll position (4.16.0).++ * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, autoApply: false, service, eventsUrl (false: off) }; the meta* tag may carry data-service, data-events and data-generation. See README "Releases".*/(function (root) {@@@ -79,7 +80,28 @@return !document.hidden && Date.now() - lastInput < IDLE_MS ? 'active' : null;}-- function reload(why) { record('reloaded', why); flush(); root.location.reload(); }++ const SCROLL_KEY = 'ov-release-scroll';++ const here = () => { try { const u = new URL(root.location.href); return u.pathname + u.search; } catch { return ''; } };++ function reload(why) {++ record('reloaded', why); flush();++ try { root.sessionStorage.setItem(SCROLL_KEY, JSON.stringify({ path: here(), y: Math.round(root.scrollY || 0), at: Date.now() })); } catch { /* */ }++ root.location.reload();++ }++ try {++ const saved = JSON.parse(root.sessionStorage.getItem(SCROLL_KEY) || 'null');++ root.sessionStorage.removeItem(SCROLL_KEY);++ if (saved && saved.y > 0 && saved.path === here() && Date.now() - saved.at < 10 * 60 * 1000) {++ const back = () => root.scrollTo(0, saved.y);++ if (document.readyState === 'complete') back(); else root.addEventListener('load', () => root.setTimeout(back, 50), { once: true });++ }++ } catch { /* */ }++ let optional = false;++ function applyOptional() {++ if (!optional || mustReload || cfg.autoApply === false || !document.hidden) return;++ const why = busy();++ if (why) { record('deferred', why, true); return; }++ reload('hidden');++ }function prompt() {if (prompted) return;prompted = true;@@@ -131,7 +153,8 @@if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());if (p.action === 'none') return;if (p.action !== 'in-place') {-- if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; }++ if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; } else optional = true;++ if (optional && document.hidden) later('apply', applyOptional, 30 * 1000);prompt();maybeReload();return;@@@ -258,8 +281,9 @@root.addEventListener('pagehide', () => flush(true, true));document.addEventListener('visibilitychange', () => {if (stopped) return;-- if (document.hidden) { flush(); maybeReload(); if (es) later('hide', hide, 5 * 60 * 1000); return; }++ if (document.hidden) { flush(); maybeReload(); if (optional) later('apply', applyOptional, 30 * 1000); if (es) later('hide', hide, 5 * 60 * 1000); return; }root.clearTimeout(t.hide); t.hide = null;++ root.clearTimeout(t.apply); t.apply = null;if (rt.state === 'hidden') open();check(false);});@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 34 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0)++const NAVBAR_BROTLI_BUDGET = 36 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0)// ── size budget and generated block ──────────────────────────{@@@ -187,6 +187,33 @@ function load(hostname, opts = {}) {assert.deepStrictEqual(regs.map((r) => r.id).sort(), ['nav-drawer', 'user-menu'], 'only the live render\'s panels are registered');assert.ok(regs.every((r) => !r.signal.aborted));}++// ── balance chips fold into one on a phone (owner, 2026-10-27): the folded chip, its short values, and setChip ──++{++ const { navbar, created } = load('openvibe.live');++ navbar.init({ service: 'live', user: { id: 1, username: 'x' }, chips: [++ { id: 'coins', icon: 'fa-coins', tone: 'gold', value: '160,774', label: 'OpenCoins', onClick() {} },++ { id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '400', title: 'Vibes balance', onClick() {} },++ ] });++ const nav = created.find(e => e.tag === 'nav');++ assert.ok(/class="ovnav-chip ovnav-chip--all" id="openvibe-chips-all"/.test(nav.innerHTML), 'the folded chip');++ assert.ok(/data-chip-mini="coins" data-tone="gold">.*?<span class="ovnav-chip-mv">161k<\/span>/.test(nav.innerHTML), '160,774 reads 161k');++ assert.ok(/data-chip-mini="vibes" data-tone="green">.*?<span class="ovnav-chip-mv">400<\/span>/.test(nav.innerHTML));++ assert.ok(nav.innerHTML.includes('aria-label="Your balances: OpenCoins 160,774, Vibes balance 400"'), 'said in full to a screen reader');++ assert.ok(/@media \(max-width: 560px\) \{\s*\.openvibe-navbar-right\.has-chips-all \.ovnav-chip\[data-chip-id\] \{ display: none; \}/.test(require('fs').readFileSync(NAVBAR_PATH, 'utf8')), 'the chips fold on a phone');++ // One chip: nothing to fold.++ const one = load('openvibe.live');++ one.navbar.init({ service: 'live', user: { id: 1, username: 'x' }, chips: [{ id: 'coins', icon: 'fa-coins', value: '5' }] });++ assert.ok(!/ovnav-chip--all/.test(one.created.find(e => e.tag === 'nav').innerHTML));++}++// ── "Across OpenVibe" in the drawer, and the icon slots that have their size before ov-icons.js arrives ──++{++ const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');++ assert.ok(src.includes('<span class="ud-label">Across OpenVibe</span>') && src.includes('data-open-launcher>All sites</button>'), 'a header with All sites');++ assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');++ assert.ok(src.includes(':is(.ovnav-launcher, .ovnav-drawer) .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');++ assert.strictEqual((src.match(/function loadIcons\(/g) || []).length, 1, 'one loadIcons (the nav glyphs); the ov-icons loader has its own name');++ assert.ok(/function queueIcons\(\)[\s\S]*?requestIdleCallback\(go/.test(src), 'ov-icons.js loads when the page settles');++}// ── rendered markup carries the segments, compact attr and the variant ────{const { navbar, created } = load('pastes.openvibe.tools', { pathname: '/mine' });nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++// An optional release applies itself while nobody is looking (release-watch.js; owner, 2026-10-27: "make sure people are++// on the latest version … if the client needs to update or something, it should just be self-aware or intelligently do++// that"). A release that only prompts (no components, not required) used to wait for the Reload button forever.++// - visible: the prompt only, never a reload under the reader;++// - hidden 30 s with nothing protected: it reloads, and saves the scroll position for the same address;++// - hidden but busy (a live camera, playing media, a protected element): deferred;++// - OVReleaseConfig.autoApply === false: never by itself.++const assert = require('assert');++const { openPage, manifestFor } = require('../release-compat');++++const A = 'aaaaaaa'; const B = 'bbbbbbb';++const M1 = manifestFor({ service: 'test', release: A });++const M2 = manifestFor({ service: 'test', release: B });++const PAGE = (extra = '') => `<!doctype html><html><head><meta name="ov-release" content="${A}" data-url="/release.json"></head><body><p>Reading.</p>${extra}</body></html>`;++++async function open({ hidden = false, html = PAGE(), config = null } = {}) {++ const state = { served: M1 }; const stored = {};++ const p = await openPage({++ url: 'https://site.test/article?x=1', html, hidden, config,++ globals: { scrollY: 840, sessionStorage: { getItem: (k) => (k in stored ? stored[k] : null), setItem: (k, v) => { stored[k] = String(v); }, removeItem: (k) => { delete stored[k]; } } },++ serve: (u) => (new URL(u).pathname === '/release.json' ? { json: state.served } : { status: 404 }),++ });++ await p.settle();++ p.state = state; p.stored = stored;++ return p;++}++const thirty = (ms) => ms === 30 * 1000;++++(async () => {++ // Visible: a new release prompts and never reloads under the reader.++ let p = await open();++ p.state.served = M2;++ await p.release.check(); await p.settle();++ assert.strictEqual(p.reloads, 0, 'not while visible');++ // Hidden: 30 s later it applies, coming back at the same scroll position.++ p.setHidden(true); await p.settle();++ assert.ok(p.timeouts().includes(30 * 1000), 'waits 30 s in the background');++ p.fireTimeouts(thirty); await p.settle();++ assert.strictEqual(p.reloads, 1, 'reloaded while hidden');++ const saved = JSON.parse(p.stored['ov-release-scroll']);++ assert.deepStrictEqual([saved.path, saved.y], ['/article?x=1', 840], 'the scroll position for this address');++ p.stop();++++ // Back before the 30 s: nothing happens.++ p = await open();++ p.state.served = M2;++ await p.release.check(); await p.settle();++ p.setHidden(true); await p.settle();++ p.setHidden(false); await p.settle();++ p.fireTimeouts(thirty); await p.settle();++ assert.strictEqual(p.reloads, 0, 'the reader came back: no reload');++ p.stop();++++ // Hidden, but something protected on the page (a live camera, a dirty form): deferred, not reloaded.++ p = await open({ html: PAGE('<div data-ov-protected></div>') });++ p.state.served = M2;++ await p.release.check(); await p.settle();++ p.setHidden(true); await p.settle();++ p.fireTimeouts(thirty); await p.settle();++ assert.strictEqual(p.reloads, 0, 'protected: deferred');++ p.stop();++++ // A site that opts out never reloads by itself.++ p = await open({ config: { autoApply: false } });++ p.state.served = M2;++ await p.release.check(); await p.settle();++ p.setHidden(true); await p.settle();++ p.fireTimeouts(thirty); await p.settle();++ assert.strictEqual(p.reloads, 0, 'autoApply false');++ p.stop();++++ // Found while already in the background (the 10-minute poll): applies 30 s later.++ p = await open({ hidden: true });++ p.state.served = M2;++ await p.release.check(); await p.settle();++ p.fireTimeouts(thirty); await p.settle();++ assert.strictEqual(p.reloads, 1, 'found while hidden');++ p.stop();++ console.log('release apply hidden: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -73,7 +73,9 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec{const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));-- assert.ok(size('release-watch.js') <= 5 * 1024, `release-watch.js is ${(size('release-watch.js') / 1024).toFixed(1)} KB brotli, budget 5 KB`);++ // 5.5 KB since 4.16.0 (5.30 KB): an optional release applies itself while the tab is hidden, and a reload keeps the++ // scroll position: +0.34 KB of code, after shortening the new comments.++ assert.ok(size('release-watch.js') <= 5.5 * 1024, `release-watch.js is ${(size('release-watch.js') / 1024).toFixed(1)} KB brotli, budget 5.5 KB`);assert.ok(size('release-update.js') <= 3.5 * 1024, `release-update.js is ${(size('release-update.js') / 1024).toFixed(1)} KB brotli, budget 3.5 KB`);}
@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.16.0 — 2026-10-27
+
+- **Balance chips fold into one on a phone** (`navbar.js`; owner, 2026-10-27: "the currencies are taking up all the
+ space … compact the two currencies into a single one when on smaller mobile resolutions … so you can still swap
+ between sites on mobile").
+ - With two or more chips at 560 px and narrower, the bar shows one chip with each icon and a short value ("161k · 400").
+ - A tap lists each in full; a row does what its chip does (a chip may now carry a `label`).
+ - `setChip` keeps both up to date. A site can show the site switcher next to its logo again (Live hid it to make room).
+- **"Across OpenVibe" in the menu is a section of its own:**
+ - a tinted block with a header (the OV mark) and **All sites** (it opens the launcher);
+ - the other sites two to a row, each with its mark (up to eight).
+- **Icons no longer pop in.**
+ - `ov-icons.js` loads once when the page settles (`requestIdleCallback`), and at once on intent (a pointer over the
+ launcher, a touch, focus), instead of the first time the launcher, menu or drawer opened.
+ - Every icon slot there has its size and a faint ring until it is drawn, so nothing moves when the glyphs arrive.
+- **An open tab stays on the latest version** (`release-watch.js`; owner, 2026-10-27: "make sure people are on the
+ latest version … if the client needs to update or something, it should just be self-aware or intelligently do that").
+ - A release that only prompted ("Update ready · Reload") used to wait for the button forever. Now it applies itself
+ while nobody is looking: after the tab has been hidden for 30 s, unless something is protected (typing, a dirty form,
+ playing media, a live camera or mic, `[data-ov-protected]`, `OVProtected()`).
+ - Any reload comes back at the same scroll position, for the same address, for ten minutes.
+ - A site can opt out with `OVReleaseConfig.autoApply = false`. Style and content releases still apply in place, and
+ required ones reload when safe, as before.
+
## 4.15.2 — 2026-10-27
## 4.15.2 — 2026-10-27
- **Showcase sections no longer fade in** (`showcase.js`, `showcase.css`). The scroll reveal animated opacity from
- **Showcase sections no longer fade in** (`showcase.js`, `showcase.css`). The scroll reveal animated opacity from
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.15.2 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.16.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.15.2",
+ "version": "4.16.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -110,6 +110,39 @@
@@ -110,6 +110,39 @@
.ovnav-drawer .ud-label { padding: 10px 12px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }
.ovnav-drawer .ud-label { padding: 10px 12px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }
.ovnav-drawer-net:empty { display: none; } .ovnav-drawer-net { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin-top: 4px; }
.ovnav-drawer-net:empty { display: none; } .ovnav-drawer-net { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin-top: 4px; }
.ovnav-drawer-net a.ovnav-net { display: flex !important; }
.ovnav-drawer-net a.ovnav-net { display: flex !important; }
+ /* "Across OpenVibe" in the menu (owner, 2026-10-27: icons, and set apart from the site's own links): a tinted
+ section, its own header with "All sites", a grid of the other sites, each with its mark. */
+ .ovnav-drawer-net:not(:empty) { margin: 8px 0 0; padding: 6px; border: 0; border-radius: 13px; background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); }
+ .ovnav-drawer-net .ovnav-net-head { display: flex; align-items: center; gap: 8px; padding: 6px 6px 8px; }
+ .ovnav-drawer-net .ovnav-net-head .ud-label { padding: 0; flex: 1; }
+ .ovnav-drawer-net .ovnav-net-all { all: unset; cursor: pointer; font-size: 12px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); padding: 3px 6px; border-radius: 7px; }
+ .ovnav-drawer-net .ovnav-net-all:hover, .ovnav-drawer-net .ovnav-net-all:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .ovnav-drawer-net .ovnav-net-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; }
+ .ovnav-drawer .ovnav-drawer-net a.ovnav-net { gap: 9px; padding: 8px; font-size: 13.5px; min-width: 0; }
+ .ovnav-drawer .ovnav-drawer-net a.ovnav-net > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ /* Icons have their size and a faint ring before ov-icons.js draws them: nothing moves or pops when they arrive. */
+ :is(.ovnav-launcher, .ovnav-drawer) .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px; border-radius: 50%; }
+ .ovnav-launcher .ovl-app > .ov-icon { width: 28px; height: 28px; }
+ .ovnav-drawer .ov-icon[data-size="22"] { width: 22px; height: 22px; }
+ :is(.ovnav-launcher, .ovnav-drawer) .ov-icon:empty { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
+ /* Several balance chips fold into one on a phone (owner, 2026-10-27: the two currencies pushed out the site
+ switcher next to the logo); a tap lists each in full. */
+ .openvibe-navbar .ovnav-chip--all { display: none; gap: 8px; padding: 0 10px; --chip-c: var(--text-secondary, #a8b3c4); }
+ .ovnav-chip-mini { display: inline-flex; align-items: center; gap: 4px; color: var(--chip-c, inherit); }
+ .ovnav-chip-mini[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip-mini[data-tone="green"] { --chip-c: #4ade80; }
+ .ovnav-chip-mini .ovnav-ic { width: 13px; height: 13px; }
+ .ovnav-chips-pop { position: absolute; top: calc(100% + 6px); right: 8px; z-index: 1002; min-width: 220px; padding: 6px; border-radius: 14px; display: none; flex-direction: column; gap: 2px;
+ background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 40px rgba(0,0,0,.45); }
+ .ovnav-chips-pop.open { display: flex; animation: openvibe-slide-down .18s ease; }
+ .ovnav-chips-pop button { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; font-size: 14px; color: var(--text-primary, #e6edf7); }
+ .ovnav-chips-pop button:hover, .ovnav-chips-pop button:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .ovnav-chips-pop button .ovnav-ic { color: var(--chip-c, inherit); width: 16px; height: 16px; }
+ .ovnav-chips-pop button[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chips-pop button[data-tone="green"] { --chip-c: #4ade80; }
+ .ovnav-chips-pop b { margin-left: auto; font-variant-numeric: tabular-nums; }
+ @media (max-width: 560px) {
+ .openvibe-navbar-right.has-chips-all .ovnav-chip[data-chip-id] { display: none; }
+ .openvibe-navbar-right.has-chips-all .ovnav-chip--all { display: inline-flex; }
+ }
@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; box-sizing: border-box; width: min(760px, 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: 0 12px 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 { position: absolute; top: calc(100% + 6px); left: 12px; box-sizing: border-box; width: min(760px, 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: 0 12px 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); }
@@ -652,6 +685,7 @@
@@ -652,6 +685,7 @@
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;
+ for (const ev of ['pointerenter', 'touchstart', 'focus']) btn.addEventListener(ev, () => loadOvIcons(), { once: true, passive: true });
let panel = null;
let panel = null;
const tile = (it, cls, here) => `<a class="${cls}${here ? ' is-here' : ''}" href="${escapeAttr(it.go || it.url)}"${here ? ' aria-current="page"' : ''}${it.desc ? ` title="${escapeAttr(it.desc)}"` : ''}><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-app' ? 28 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
const tile = (it, cls, here) => `<a class="${cls}${here ? ' is-here' : ''}" href="${escapeAttr(it.go || it.url)}"${here ? ' aria-current="page"' : ''}${it.desc ? ` title="${escapeAttr(it.desc)}"` : ''}><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-app' ? 28 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
const siteItems = (frame) => (frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline.replace(/\.\s*$/, ''), icon: n.icon || n.id, url: n.url, group: n.group })) : LAUNCHER_SITES);
const siteItems = (frame) => (frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline.replace(/\.\s*$/, ''), icon: n.icon || n.id, url: n.url, group: n.group })) : LAUNCHER_SITES);
@@ -699,7 +733,7 @@
@@ -699,7 +733,7 @@
bindDisplayControls(panel.querySelector('.ovl-display'));
bindDisplayControls(panel.querySelector('.ovl-display'));
nav.appendChild(panel);
nav.appendChild(panel);
regPanel(panel, 'launcher', close);
regPanel(panel, 'launcher', close);
- if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); }
+ loadOvIcons();
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()));
@@ -736,6 +770,63 @@
@@ -736,6 +770,63 @@
function chipsHTML(chips, cls) {
function chipsHTML(chips, cls) {
return (Array.isArray(chips) ? chips : []).filter(Boolean).map(c => `<button type="button" class="${cls || 'ovnav-chip'}" data-chip-id="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}${c.title ? ` title="${escapeAttr(c.title)}"` : ''}${c.hidden ? ' hidden' : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-v"${c.valueId ? ` id="${escapeAttr((cls ? 'menu-' : '') + c.valueId)}"` : ''}>${escapeAttr(c.value == null ? '' : c.value)}</span></button>`).join('');
return (Array.isArray(chips) ? chips : []).filter(Boolean).map(c => `<button type="button" class="${cls || 'ovnav-chip'}" data-chip-id="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}${c.title ? ` title="${escapeAttr(c.title)}"` : ''}${c.hidden ? ' hidden' : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-v"${c.valueId ? ` id="${escapeAttr((cls ? 'menu-' : '') + c.valueId)}"` : ''}>${escapeAttr(c.value == null ? '' : c.value)}</span></button>`).join('');
}
}
+ /** "160,774" → "161k", for the folded chip; anything that is not a number stays as it is. */
+ function shortValue(v) {
+ const s = v == null ? '' : String(v); const n = Number(s.replace(/[,\s]/g, ''));
+ if (!s || !isFinite(n)) return s;
+ const a = Math.abs(n);
+ if (a >= 1e6) return `${(n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, '')}M`;
+ if (a >= 1e4) return `${Math.round(n / 1e3)}k`;
+ if (a >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, '')}k`;
+ return String(Math.round(n));
+ }
+ const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0];
+ /** The chips folded into one (shown instead of them on a phone): each chip's icon and short value. */
+ function chipsAllHTML(chips) {
+ const list = (Array.isArray(chips) ? chips : []).filter((c) => c && !c.hidden);
+ if (list.length < 2) return '';
+ return `<button type="button" class="ovnav-chip ovnav-chip--all" id="openvibe-chips-all" aria-haspopup="true" aria-expanded="false" aria-label="Your balances: ${escapeAttr(list.map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', '))}">${list.map((c) => `<span class="ovnav-chip-mini" data-chip-mini="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-mv">${escapeAttr(shortValue(c.value))}</span></span>`).join('')}</button>`;
+ }
+ function bindChipsAll(nav) {
+ const btn = nav.querySelector('#openvibe-chips-all'); if (!btn || typeof btn.addEventListener !== 'function') return;
+ const right = typeof btn.closest === 'function' ? btn.closest('.openvibe-navbar-right') : null;
+ if (right && right.classList) right.classList.add('has-chips-all');
+ let pop = null;
+ const close = () => { if (pop) pop.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (pop && pop.classList.contains('open')) return close();
+ if (!pop) { pop = document.createElement('div'); pop.className = 'ovnav-chips-pop'; pop.setAttribute('role', 'menu'); nav.appendChild(pop); regPanel(pop, 'chips', close); }
+ const list = (_config.chips || []).filter((c) => c && !c.hidden);
+ pop.innerHTML = list.map((c) => `<button type="button" role="menuitem" data-chip-row="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}>${c.icon ? navIcon(c.icon) : ''}<span>${escapeAttr(chipLabel(c))}</span><b>${escapeAttr(c.value == null ? '' : c.value)}</b></button>`).join('');
+ pop.querySelectorAll('[data-chip-row]').forEach((row) => row.addEventListener('click', (ev) => {
+ ev.stopPropagation(); close();
+ const c = list.find((x) => x.id === row.getAttribute('data-chip-row'));
+ if (c && typeof c.onClick === 'function') c.onClick(ev);
+ }));
+ pop.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
+ const first = pop.querySelector('button'); if (first) first.focus();
+ });
+ }
+ /**
+ * ov-icons.js draws every .ov-icon in the launcher, the menu and the drawer. It used to load the first time one of them
+ * opened, so their icons popped in a moment later and the rows moved (owner, 2026-10-27). Now it loads once, quietly,
+ * after the page settles, and at once on intent (a pointer over the launcher, a touch, focus); the slots have their
+ * size meanwhile.
+ */
+ let _iconsQueued = false;
+ function loadOvIcons(scope) {
+ if (root.OpenVibeIcons) { if (scope && root.OpenVibeIcons.mount) root.OpenVibeIcons.mount(scope); return; }
+ if (document.getElementById('ov-icons-loader')) return;
+ try { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
+ }
+ function queueIcons() {
+ if (_iconsQueued || root.OpenVibeIcons) return;
+ _iconsQueued = true;
+ const go = () => loadOvIcons();
+ if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 4000 });
+ else { const t = setTimeout(go, 2500); if (t && typeof t.unref === 'function') t.unref(); }
+ }
function bindChips(scope) {
function bindChips(scope) {
(_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {
(_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {
if (!c || typeof c.onClick !== 'function') return;
if (!c || typeof c.onClick !== 'function') return;
@@ -774,7 +865,12 @@
@@ -774,7 +865,12 @@
const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd'));
const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd'));
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
if (burger && drawer) {
if (burger && drawer) {
- burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); });
+ burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer); });
+ drawer.addEventListener('click', (e) => {
+ const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return;
+ e.preventDefault(); e.stopPropagation(); closeDrawer();
+ const lb = nav.querySelector('#openvibe-launcher-btn'); if (lb) lb.click();
+ });
document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); }, onDocument());
document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); }, onDocument());
regPanel(drawer, 'nav-drawer', closeDrawer);
regPanel(drawer, 'nav-drawer', closeDrawer);
}
}
@@ -849,10 +945,14 @@
@@ -849,10 +945,14 @@
if (!frame) return '';
if (!frame) return '';
const here = currentHost().toLowerCase();
const here = currentHost().toLowerCase();
const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
- const max = inDrawer ? 6 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : 4);
+ const max = inDrawer ? 8 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : 4);
const pick = frame.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
const pick = frame.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
if (!pick.length) return '';
if (!pick.length) return '';
- return (inDrawer ? '<div class="ud-label">Across OpenVibe</div>' : `<span class="ovnav-sep" aria-hidden="true"></span>`) + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
+ if (inDrawer) {
+ return `<div class="ovnav-net-head"><span class="ov-icon" data-icon="ov" data-size="22" data-fx="none"></span><span class="ud-label">Across OpenVibe</span>${_config.launcher === false ? '' : '<button type="button" class="ovnav-net-all" data-open-launcher>All sites</button>'}</div>`
+ + `<div class="ovnav-net-grid">${pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}"><span class="ov-icon" data-icon="${escapeAttr(n.icon || n.id)}" data-size="22" data-fx="none"></span><span>${escapeAttr(n.name)}</span></a>`).join('')}</div>`;
+ }
+ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
}
}
/** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */
/** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */
@@ -1488,7 +1588,7 @@
@@ -1488,7 +1588,7 @@
</div>
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-right">
<div class="openvibe-navbar-right">
- ${u ? chipsHTML(_config.chips) : ''}
+ ${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
<div id="openvibe-bell-mount"></div>
<div id="openvibe-bell-mount"></div>
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
@@ -1499,6 +1599,8 @@
@@ -1499,6 +1599,8 @@
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
bindLinks(nav, links);
bindChips(nav);
bindChips(nav);
+ bindChipsAll(nav);
+ queueIcons();
bindLauncher(nav);
bindLauncher(nav);
countView();
countView();
// Pages that wire the bell themselves run right after init(); give them the first go.
// Pages that wire the bell themselves run right after init(); give them the first go.
@@ -1585,7 +1687,7 @@
@@ -1585,7 +1687,7 @@
avatarBtn.addEventListener('click', () => {
avatarBtn.addEventListener('click', () => {
dropdown.classList.toggle('open');
dropdown.classList.toggle('open');
const ask = dropdown.querySelector('[data-ov-ask]'); if (ask) ask.href = askVibeHref(sel ?? selectedText()); sel = null;
const ask = dropdown.querySelector('[data-ov-ask]'); if (ask) ask.href = askVibeHref(sel ?? selectedText()); sel = null;
- if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; if (root.OpenVibeIcons) root.OpenVibeIcons.mount(dropdown); else if (!document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = sibling('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } }
+ if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; loadOvIcons(dropdown); }
});
});
// Close on outside click
// Close on outside click
@@ -1742,6 +1844,10 @@
@@ -1742,6 +1844,10 @@
const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];
const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];
lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));
lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));
document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; });
document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; });
+ document.querySelectorAll(`[data-chip-mini="${id}"] .ovnav-chip-mv`).forEach(el => { el.textContent = shortValue(value); });
+ document.querySelectorAll(`[data-chip-row="${id}"] b`).forEach(el => { el.textContent = value == null ? '' : String(value); });
+ const all = document.getElementById('openvibe-chips-all');
+ if (all) all.setAttribute('aria-label', `Your balances: ${(_config.chips || []).filter((c) => c && !c.hidden).map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', ')}`);
},
},
/** Patch a top-level or dropdown link by id: updateLink('broadcast', { hidden: false, dot: true, label }). */
/** Patch a top-level or dropdown link by id: updateLink('broadcast', { hidden: false, dot: true, label }). */
updateLink(id, patch) {
updateLink(id, patch) {
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.15.2",
+ "version": "4.16.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.15.2",
+ "version": "4.16.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.15.2",
+ "version": "4.16.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
* min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no
* min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no
* focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live
* focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live
* camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).
* camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).
- * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta
+ * An optional release applies itself after 30 s hidden with nothing protected, at the same scroll position (4.16.0).
+ * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, autoApply: false, service, eventsUrl (false: off) }; the meta
* tag may carry data-service, data-events and data-generation. See README "Releases".
* tag may carry data-service, data-events and data-generation. See README "Releases".
*/
*/
(function (root) {
(function (root) {
@@ -79,7 +80,28 @@
@@ -79,7 +80,28 @@
return !document.hidden && Date.now() - lastInput < IDLE_MS ? 'active' : null;
return !document.hidden && Date.now() - lastInput < IDLE_MS ? 'active' : null;
}
}
- function reload(why) { record('reloaded', why); flush(); root.location.reload(); }
+ const SCROLL_KEY = 'ov-release-scroll';
+ const here = () => { try { const u = new URL(root.location.href); return u.pathname + u.search; } catch { return ''; } };
+ function reload(why) {
+ record('reloaded', why); flush();
+ try { root.sessionStorage.setItem(SCROLL_KEY, JSON.stringify({ path: here(), y: Math.round(root.scrollY || 0), at: Date.now() })); } catch { /* */ }
+ root.location.reload();
+ }
+ try {
+ const saved = JSON.parse(root.sessionStorage.getItem(SCROLL_KEY) || 'null');
+ root.sessionStorage.removeItem(SCROLL_KEY);
+ if (saved && saved.y > 0 && saved.path === here() && Date.now() - saved.at < 10 * 60 * 1000) {
+ const back = () => root.scrollTo(0, saved.y);
+ if (document.readyState === 'complete') back(); else root.addEventListener('load', () => root.setTimeout(back, 50), { once: true });
+ }
+ } catch { /* */ }
+ let optional = false;
+ function applyOptional() {
+ if (!optional || mustReload || cfg.autoApply === false || !document.hidden) return;
+ const why = busy();
+ if (why) { record('deferred', why, true); return; }
+ reload('hidden');
+ }
function prompt() {
function prompt() {
if (prompted) return;
if (prompted) return;
prompted = true;
prompted = true;
@@ -131,7 +153,8 @@
@@ -131,7 +153,8 @@
if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());
if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());
if (p.action === 'none') return;
if (p.action === 'none') return;
if (p.action !== 'in-place') {
if (p.action !== 'in-place') {
- if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; }
+ if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; } else optional = true;
+ if (optional && document.hidden) later('apply', applyOptional, 30 * 1000);
prompt();
prompt();
maybeReload();
maybeReload();
return;
return;
@@ -258,8 +281,9 @@
@@ -258,8 +281,9 @@
root.addEventListener('pagehide', () => flush(true, true));
root.addEventListener('pagehide', () => flush(true, true));
document.addEventListener('visibilitychange', () => {
document.addEventListener('visibilitychange', () => {
if (stopped) return;
if (stopped) return;
- if (document.hidden) { flush(); maybeReload(); if (es) later('hide', hide, 5 * 60 * 1000); return; }
+ if (document.hidden) { flush(); maybeReload(); if (optional) later('apply', applyOptional, 30 * 1000); if (es) later('hide', hide, 5 * 60 * 1000); return; }
root.clearTimeout(t.hide); t.hide = null;
root.clearTimeout(t.hide); t.hide = null;
+ root.clearTimeout(t.apply); t.apply = null;
if (rt.state === 'hidden') open();
if (rt.state === 'hidden') open();
check(false);
check(false);
});
});
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 34 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0)
+const NAVBAR_BROTLI_BUDGET = 36 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -187,6 +187,33 @@ function load(hostname, opts = {}) {
@@ -187,6 +187,33 @@ function load(hostname, opts = {}) {
assert.deepStrictEqual(regs.map((r) => r.id).sort(), ['nav-drawer', 'user-menu'], 'only the live render\'s panels are registered');
assert.deepStrictEqual(regs.map((r) => r.id).sort(), ['nav-drawer', 'user-menu'], 'only the live render\'s panels are registered');
assert.ok(regs.every((r) => !r.signal.aborted));
assert.ok(regs.every((r) => !r.signal.aborted));
}
}
+// ── balance chips fold into one on a phone (owner, 2026-10-27): the folded chip, its short values, and setChip ──
+{
+ const { navbar, created } = load('openvibe.live');
+ navbar.init({ service: 'live', user: { id: 1, username: 'x' }, chips: [
+ { id: 'coins', icon: 'fa-coins', tone: 'gold', value: '160,774', label: 'OpenCoins', onClick() {} },
+ { id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '400', title: 'Vibes balance', onClick() {} },
+ ] });
+ const nav = created.find(e => e.tag === 'nav');
+ assert.ok(/class="ovnav-chip ovnav-chip--all" id="openvibe-chips-all"/.test(nav.innerHTML), 'the folded chip');
+ assert.ok(/data-chip-mini="coins" data-tone="gold">.*?<span class="ovnav-chip-mv">161k<\/span>/.test(nav.innerHTML), '160,774 reads 161k');
+ assert.ok(/data-chip-mini="vibes" data-tone="green">.*?<span class="ovnav-chip-mv">400<\/span>/.test(nav.innerHTML));
+ assert.ok(nav.innerHTML.includes('aria-label="Your balances: OpenCoins 160,774, Vibes balance 400"'), 'said in full to a screen reader');
+ assert.ok(/@media \(max-width: 560px\) \{\s*\.openvibe-navbar-right\.has-chips-all \.ovnav-chip\[data-chip-id\] \{ display: none; \}/.test(require('fs').readFileSync(NAVBAR_PATH, 'utf8')), 'the chips fold on a phone');
+ // One chip: nothing to fold.
+ const one = load('openvibe.live');
+ one.navbar.init({ service: 'live', user: { id: 1, username: 'x' }, chips: [{ id: 'coins', icon: 'fa-coins', value: '5' }] });
+ assert.ok(!/ovnav-chip--all/.test(one.created.find(e => e.tag === 'nav').innerHTML));
+}
+// ── "Across OpenVibe" in the drawer, and the icon slots that have their size before ov-icons.js arrives ──
+{
+ const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');
+ assert.ok(src.includes('<span class="ud-label">Across OpenVibe</span>') && src.includes('data-open-launcher>All sites</button>'), 'a header with All sites');
+ assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');
+ assert.ok(src.includes(':is(.ovnav-launcher, .ovnav-drawer) .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');
+ assert.strictEqual((src.match(/function loadIcons\(/g) || []).length, 1, 'one loadIcons (the nav glyphs); the ov-icons loader has its own name');
+ assert.ok(/function queueIcons\(\)[\s\S]*?requestIdleCallback\(go/.test(src), 'ov-icons.js loads when the page settles');
+}
// ── rendered markup carries the segments, compact attr and the variant ────
// ── rendered markup carries the segments, compact attr and the variant ────
{
{
const { navbar, created } = load('pastes.openvibe.tools', { pathname: '/mine' });
const { navbar, created } = load('pastes.openvibe.tools', { pathname: '/mine' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+// An optional release applies itself while nobody is looking (release-watch.js; owner, 2026-10-27: "make sure people are
+// on the latest version … if the client needs to update or something, it should just be self-aware or intelligently do
+// that"). A release that only prompts (no components, not required) used to wait for the Reload button forever.
+// - visible: the prompt only, never a reload under the reader;
+// - hidden 30 s with nothing protected: it reloads, and saves the scroll position for the same address;
+// - hidden but busy (a live camera, playing media, a protected element): deferred;
+// - OVReleaseConfig.autoApply === false: never by itself.
+const assert = require('assert');
+const { openPage, manifestFor } = require('../release-compat');
+
+const A = 'aaaaaaa'; const B = 'bbbbbbb';
+const M1 = manifestFor({ service: 'test', release: A });
+const M2 = manifestFor({ service: 'test', release: B });
+const PAGE = (extra = '') => `<!doctype html><html><head><meta name="ov-release" content="${A}" data-url="/release.json"></head><body><p>Reading.</p>${extra}</body></html>`;
+
+async function open({ hidden = false, html = PAGE(), config = null } = {}) {
+ const state = { served: M1 }; const stored = {};
+ const p = await openPage({
+ url: 'https://site.test/article?x=1', html, hidden, config,
+ globals: { scrollY: 840, sessionStorage: { getItem: (k) => (k in stored ? stored[k] : null), setItem: (k, v) => { stored[k] = String(v); }, removeItem: (k) => { delete stored[k]; } } },
+ serve: (u) => (new URL(u).pathname === '/release.json' ? { json: state.served } : { status: 404 }),
+ });
+ await p.settle();
+ p.state = state; p.stored = stored;
+ return p;
+}
+const thirty = (ms) => ms === 30 * 1000;
+
+(async () => {
+ // Visible: a new release prompts and never reloads under the reader.
+ let p = await open();
+ p.state.served = M2;
+ await p.release.check(); await p.settle();
+ assert.strictEqual(p.reloads, 0, 'not while visible');
+ // Hidden: 30 s later it applies, coming back at the same scroll position.
+ p.setHidden(true); await p.settle();
+ assert.ok(p.timeouts().includes(30 * 1000), 'waits 30 s in the background');
+ p.fireTimeouts(thirty); await p.settle();
+ assert.strictEqual(p.reloads, 1, 'reloaded while hidden');
+ const saved = JSON.parse(p.stored['ov-release-scroll']);
+ assert.deepStrictEqual([saved.path, saved.y], ['/article?x=1', 840], 'the scroll position for this address');
+ p.stop();
+
+ // Back before the 30 s: nothing happens.
+ p = await open();
+ p.state.served = M2;
+ await p.release.check(); await p.settle();
+ p.setHidden(true); await p.settle();
+ p.setHidden(false); await p.settle();
+ p.fireTimeouts(thirty); await p.settle();
+ assert.strictEqual(p.reloads, 0, 'the reader came back: no reload');
+ p.stop();
+
+ // Hidden, but something protected on the page (a live camera, a dirty form): deferred, not reloaded.
+ p = await open({ html: PAGE('<div data-ov-protected></div>') });
+ p.state.served = M2;
+ await p.release.check(); await p.settle();
+ p.setHidden(true); await p.settle();
+ p.fireTimeouts(thirty); await p.settle();
+ assert.strictEqual(p.reloads, 0, 'protected: deferred');
+ p.stop();
+
+ // A site that opts out never reloads by itself.
+ p = await open({ config: { autoApply: false } });
+ p.state.served = M2;
+ await p.release.check(); await p.settle();
+ p.setHidden(true); await p.settle();
+ p.fireTimeouts(thirty); await p.settle();
+ assert.strictEqual(p.reloads, 0, 'autoApply false');
+ p.stop();
+
+ // Found while already in the background (the 10-minute poll): applies 30 s later.
+ p = await open({ hidden: true });
+ p.state.served = M2;
+ await p.release.check(); await p.settle();
+ p.fireTimeouts(thirty); await p.settle();
+ assert.strictEqual(p.reloads, 1, 'found while hidden');
+ p.stop();
+ console.log('release apply hidden: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -73,7 +73,9 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
@@ -73,7 +73,9 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
{
{
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
- assert.ok(size('release-watch.js') <= 5 * 1024, `release-watch.js is ${(size('release-watch.js') / 1024).toFixed(1)} KB brotli, budget 5 KB`);
+ // 5.5 KB since 4.16.0 (5.30 KB): an optional release applies itself while the tab is hidden, and a reload keeps the
+ // scroll position: +0.34 KB of code, after shortening the new comments.
+ assert.ok(size('release-watch.js') <= 5.5 * 1024, `release-watch.js is ${(size('release-watch.js') / 1024).toFixed(1)} KB brotli, budget 5.5 KB`);
assert.ok(size('release-update.js') <= 3.5 * 1024, `release-update.js is ${(size('release-update.js') / 1024).toFixed(1)} KB brotli, budget 3.5 KB`);
assert.ok(size('release-update.js') <= 3.5 * 1024, `release-update.js is ${(size('release-update.js') / 1024).toFixed(1)} KB brotli, budget 3.5 KB`);
}
}