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)

OP OpenVibers committed 16 days ago · f77682f978 · 11 files, +281 −17

* 4.16.0: balance chips fold into one on a phone; Across OpenVibe is a section with site marks and All sites; ov-icons.js loads when the page settles, icon slots keep their size

* 4.16.0: an optional release applies itself after 30 s hidden with nothing protected, and a reload keeps the scroll position; navbar budget 36 KB, release-watch 5.5 KB

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d32bafb..b0cce55 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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
ddiff --git a/README.md b/README.md
iindex 25f82b0..f195e22 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex db23756..a96ca74 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/navbar.js b/navbar.js
iindex 2039940..05f4883 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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) {
ddiff --git a/package-lock.json b/package-lock.json
iindex 2d50397..5b21ce7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 0a43b89..3ffc61f 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/release-watch.js b/release-watch.js
iindex 4f51030..759b150 100644
---- a/release-watch.js
++++ b/release-watch.js
@@@ -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);
});
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 6f0394b..476a3db 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -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 ──────────────────────────
{
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex 34234de..8c92ec0 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -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' });
ddiff --git a/test/release-apply-hidden.test.js b/test/release-apply-hidden.test.js
nnew file mode 100644
iindex 0000000..e043b14
---- /dev/null
++++ b/test/release-apply-hidden.test.js
@@@ -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); });
ddiff --git a/test/release-update.test.js b/test/release-update.test.js
iindex 847d5e9..d492a5d 100644
---- a/test/release-update.test.js
++++ b/test/release-update.test.js
@@@ -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`);
}