openvibers/openvibe.shared Commits

4.17.0: wallet: true puts OpenCoins and Vibes in the bar on any site (#139)

OP OpenVibers committed 16 days ago · 6a000c2153 · 8 files, +200 −6

The owner (2026-10-27): "make that as a feature in the shared nav bar … maybe we'll put it on games too".
The navbar asks the Network and Billing itself (at most once a minute, again when the tab comes back), keeps the
last balances per person so the next page paints them at once, hides Vibes while Billing answers 409, and leaves a
site's own coins/vibes chips (Live) alone. On a phone they fold into one chip (4.16.0). navbar.js budget 36 → 37 KB
(36.2 KB). Checked on openvibe.games at 320, 390 and 1280 px with the probe: chips, the folded chip and its list,
the kept balances painted before the requests answer, no overflow.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b0cce55..12201b2 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,18 @@ 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.17.0 — 2026-10-27
++
++- **`wallet: true`: OpenCoins and Vibes in the bar on any site** (`navbar.js`; owner, 2026-10-27: "make that as a
++ feature in the shared nav bar … maybe we'll put it on games too").
++ - The navbar asks the Network (`/api/coins/me`) and Billing (`/api/me/vibes`) itself, at most once a minute, and again
++ when the tab comes back.
++ - The last balances are kept per person (`localStorage`), so the next page paints the chips at once: no empty chips,
++ no jump.
++ - Vibes hides while Billing answers 409 (not the ledger yet). A site's own chips with the ids `coins`/`vibes` (Live)
++ win; signed out, nothing is asked.
++ - On a phone they fold into one chip (4.16.0).
++
## 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
ddiff --git a/README.md b/README.md
iindex a392c75..094fb91 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.16.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.17.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.
@@@ -650,6 +650,15 @@ Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,
chip the first time and re-authenticates silently afterwards, 'silent' only
re-authenticates, false opts out; fedcmLogin is the POST target for the
assertion (default: this site's /auth/fedcm).
++Balances: chips: [{ id, icon, value, label, tone, title, onClick, hidden }]
++ are status chips beside the bell (Live's OpenCoins and Vibes); setChip(id,
++ value) updates one. Two or more fold into one chip at 560 px and narrower.
++ wallet: true (4.17) puts OpenCoins and Vibes there on any site without
++ writing them: the navbar asks the Network and Billing, keeps the last
++ balances per person so the next page paints them at once, refreshes after a
++ minute away, and hides Vibes while Billing is not the ledger (409). A site's
++ own chips with the ids coins/vibes win. Every site's account menu shows both
++ balances in its header either way.
Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
previous render's document and window listeners and its panels.js
registrations, so the old bar can be collected.
ddiff --git a/STATUS.json b/STATUS.json
iindex 310c3a7..7f6873b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.16.0",
++ "version": "4.17.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 05f4883..715a6b1 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -1552,7 +1552,69 @@
document.head.appendChild(sc);
}
++ /**
++ * wallet: true — OpenCoins and Vibes as chips in the bar, on any site (owner, 2026-10-27: "make that as a feature in the
++ * shared nav bar … maybe we'll put it on games too"). A site that sets chips of its own with those ids (Live) keeps
++ * them; elsewhere the balances stay in the menu's header. The last balances are kept per person, so the chips paint
++ * at once without moving the bar; they refresh from the Network and Billing in the background, and again when the
++ * tab comes back after a minute. On a phone they fold into one chip like Live's (4.16.0).
++ */
++ const WALLET_CHIPS = [
++ { id: 'coins', icon: 'fa-coins', tone: 'gold', label: 'OpenCoins', title: 'OpenCoins', href: 'https://openvibe.network/my#coins' },
++ { id: 'vibes', icon: 'fa-wallet', tone: 'green', label: 'Vibes', title: 'Your Vibes', href: 'https://openvibe.live/#vibes' },
++ ];
++ let _walletFor = null, _walletAt = 0, _walletWatch = false;
++ const walletKey = (u) => `ov-wallet:${u.subject || u.id || u.username || ''}`;
++ function savedWallet(u) { try { return JSON.parse(localStorage.getItem(walletKey(u)) || '{}') || {}; } catch { return {}; } }
++ function syncWalletChips() {
++ if (!_config.wallet) return;
++ const u = _config.user;
++ let chips = (_config.chips || []).filter((c) => !(c && c.__wallet));
++ if (u && !u.is_anon) {
++ const saved = savedWallet(u), have = new Set(chips.map((c) => c && c.id));
++ chips = WALLET_CHIPS.filter((w) => !have.has(w.id)).map((w) => Object.assign({}, w, {
++ __wallet: true, value: saved[w.id] == null ? '' : saved[w.id], hidden: saved[w.id] == null,
++ onClick: () => { try { root.location.href = w.href; } catch { /* */ } },
++ })).concat(chips);
++ }
++ _config.chips = chips;
++ }
++ function loadWalletChips() {
++ const u = _config.user;
++ if (!_config.wallet || !u || u.is_anon || !_config.token || typeof fetch !== 'function') return;
++ const key = walletKey(u);
++ if (_walletFor === key && Date.now() - _walletAt < 60000) return;
++ _walletFor = key; _walletAt = Date.now();
++ if (!_walletWatch && typeof document !== 'undefined' && document.addEventListener) {
++ _walletWatch = true;
++ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') loadWalletChips(); });
++ }
++ const auth = { Authorization: `Bearer ${_config.token}` };
++ const saved = savedWallet(u);
++ let shown = false;
++ const put = (id, n) => {
++ const mine = (_config.chips || []).find((c) => c && c.id === id && c.__wallet);
++ if (!mine) return;
++ if (n == null) { delete saved[id]; if (!mine.hidden) { mine.hidden = true; shown = true; } return; }
++ saved[id] = n.toLocaleString();
++ if (mine.hidden) shown = true;
++ OpenVibeNavbar.setChip(id, saved[id], { hidden: false });
++ };
++ const num = (v) => (isFinite(v) ? v : null);
++ const coins = fetch(`${_config.apiBase}/api/coins/me`, { headers: auth, credentials: 'include' })
++ .then(async (r) => { if (r.ok) { const j = await r.json(); put('coins', num(Number(j.balance ?? (j.wallet && j.wallet.balance)))); } });
++ // Billing answers 409 until it is the ledger: no Vibes chip then.
++ const vibes = fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })
++ .then(async (r) => { if (r.ok) put('vibes', num(Number((await r.json()).credit))); else if (r.status === 409) put('vibes', null); });
++ Promise.all([coins.catch(() => {}), vibes.catch(() => {})]).then(() => {
++ try { localStorage.setItem(walletKey(u), JSON.stringify(saved)); } catch { /* */ }
++ // A chip that appeared or went away changes the folded phone chip: draw the bar once more.
++ if (shown && _config.user === u && _navEl) render();
++ });
++ }
++
function render() {
++ syncWalletChips();
// A site may put state classes on the bar (Live's transparent hero mode). A re-render must not lose them.
let carried = []; try { carried = Array.from((_navEl && _navEl.classList) || []).filter(c => c !== 'openvibe-navbar'); } catch { carried = []; }
if (_navEl) _navEl.remove();
@@@ -1743,6 +1805,7 @@
_navEl = nav;
attachAvatarFallbacks(nav);
hookTop();
++ loadWalletChips();
return nav;
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 86538da..9f468c6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.16.0",
++ "version": "4.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.16.0",
++ "version": "4.17.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex e3a5fce..2edf085 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.16.0",
++ "version": "4.17.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 476a3db..74007af 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 = 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)
++const NAVBAR_BROTLI_BUDGET = 37 * 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); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/navbar-wallet-chips.test.js b/test/navbar-wallet-chips.test.js
nnew file mode 100644
iindex 0000000..43b2b7a
---- /dev/null
++++ b/test/navbar-wallet-chips.test.js
@@@ -0,0 +1,110 @@
++'use strict';
++// wallet: true — OpenCoins and Vibes as chips in the bar on any site (owner, 2026-10-27: "make that as a feature in the
++// shared nav bar … maybe we'll put it on games too"). Checked with the same stubbed globals as navbar-brand.test.js:
++// - a signed-in page with wallet: true asks the Network and Billing once, and the chips appear with the balances;
++// - the balances are kept per person, so the next page paints them at once (no empty chips, no jump);
++// - Billing's 409 (not the ledger yet) hides Vibes; a signed-out page and a site without wallet ask nothing;
++// - a site with chips of its own under those ids (Live) keeps them, no second pair.
++const assert = require('assert');
++const path = require('path');
++const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');
++
++function stubElement() {
++ return {
++ style: {}, textContent: '', className: '', id: '', innerHTML: '', dataset: {}, attrs: {}, children: [],
++ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
++ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return this.attrs[k] ?? null; },
++ appendChild(c) { this.children.push(c); }, prepend(c) { this.children.unshift(c); }, remove() {}, addEventListener() {},
++ querySelector() { return stubElement(); }, querySelectorAll() { return []; },
++ };
++}
++
++function load(store, answers) {
++ global.location = { hostname: 'openvibe.games', href: 'https://openvibe.games/', pathname: '/', protocol: 'https:' };
++ global.window = { location: global.location };
++ try { Object.defineProperty(global, 'navigator', { value: { userAgent: 'test' }, configurable: true }); } catch { /* */ }
++ const navs = [];
++ global.document = {
++ cookie: '', head: stubElement(), body: stubElement(), title: 'T', visibilityState: 'visible',
++ getElementById: () => null, querySelectorAll: () => [],
++ createElement: (tag) => { const e = stubElement(); e.tag = tag; if (tag === 'nav') navs.push(e); return e; },
++ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
++ };
++ global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };
++ global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } };
++ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };
++ const calls = [];
++ global.fetch = async (url) => {
++ calls.push(url);
++ const a = /\/api\/me\/vibes$/.test(url) ? answers.vibes : /\/api\/coins\/me$/.test(url) ? answers.coins : { status: 404, body: {} };
++ return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
++ };
++ delete require.cache[require.resolve(NAVBAR_PATH)];
++ return { navbar: require(NAVBAR_PATH), navs, calls };
++}
++const settle = () => new Promise((r) => setTimeout(r, 20));
++const wallet = (calls) => calls.filter((u) => /\/api\/(coins\/me|me\/vibes)$/.test(u));
++const chip = (html, id) => (new RegExp(`<button[^>]*data-chip-id="${id}"[^>]*>`).exec(html) || [''])[0];
++const user = { id: 7, subject: 'usr_7', username: 'maya' };
++const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.network', user, token: 'tok', wallet: true };
++
++(async () => {
++ const store = {};
++ const answers = { coins: { status: 200, body: { balance: 160774 } }, vibes: { status: 200, body: { credit: 2500 } } };
++
++ // First visit: no balances known yet, so the chips wait (hidden), then appear with the numbers and fold on a phone.
++ let { navbar, navs, calls } = load(store, answers);
++ navbar.init(base);
++ assert.match(chip(navs[0].innerHTML, 'coins'), /hidden/, 'nothing to show before the balances arrive');
++ await settle();
++ assert.deepStrictEqual(wallet(calls).sort(), ['https://billing.openvibe.network/api/me/vibes', 'https://openvibe.network/api/coins/me']);
++ const last = navs[navs.length - 1].innerHTML;
++ assert.ok(navs.length >= 2, 'drawn again once the chips appeared');
++ assert.doesNotMatch(chip(last, 'coins'), /hidden/);
++ assert.match(last, /data-chip-id="coins"[^>]*>[\s\S]*?160,774/);
++ assert.match(last, /data-chip-id="vibes"[^>]*>[\s\S]*?2,500/);
++ assert.match(last, /id="openvibe-chips-all"/, 'one folded chip for phones');
++ assert.match(last, /aria-label="Your balances: OpenCoins 160,774, Vibes 2,500"/);
++ assert.deepStrictEqual(JSON.parse(store['ov-wallet:usr_7']), { coins: '160,774', vibes: '2,500' });
++
++ // Drawn again on the same page within a minute: no second request.
++ const before = wallet(calls).length;
++ navbar.init(base);
++ await settle();
++ assert.strictEqual(wallet(calls).length, before, 'at most once a minute');
++
++ // Next page: the kept balances paint at once.
++ ({ navbar, navs, calls } = load(store, answers));
++ navbar.init(base);
++ assert.doesNotMatch(chip(navs[0].innerHTML, 'coins'), /hidden/);
++ assert.match(navs[0].innerHTML, /data-chip-id="coins"[^>]*>[\s\S]*?160,774/, 'painted from the kept balance');
++ await settle();
++
++ // Billing not the ledger yet (409): the Vibes chip goes away and is forgotten; OpenCoins stay.
++ answers.vibes = { status: 409, body: { code: 'billing.not_authoritative' } };
++ ({ navbar, navs, calls } = load(store, answers));
++ navbar.init(base);
++ await settle();
++ const after409 = navs[navs.length - 1].innerHTML;
++ assert.match(chip(after409, 'vibes'), /hidden/);
++ assert.doesNotMatch(chip(after409, 'coins'), /hidden/);
++ assert.doesNotMatch(after409, /id="openvibe-chips-all"/, 'one chip left: nothing to fold');
++ assert.deepStrictEqual(JSON.parse(store['ov-wallet:usr_7']), { coins: '160,774' });
++
++ // Signed out, or a site that did not ask: no chips, no requests.
++ ({ navbar, navs, calls } = load({}, answers));
++ navbar.init({ ...base, user: null, token: null });
++ navbar.init({ ...base, wallet: false });
++ await settle();
++ assert.strictEqual(wallet(calls).length, 0);
++ assert.doesNotMatch(navs.map((n) => n.innerHTML).join(''), /data-chip-id="coins"/);
++
++ // Live sets its own coins and vibes chips: they stay, with no second pair.
++ ({ navbar, navs, calls } = load({}, answers));
++ navbar.init({ ...base, chips: [{ id: 'coins', icon: 'fa-coins', value: '9' }, { id: 'vibes', icon: 'fa-wallet', value: '1' }] });
++ await settle();
++ const live = navs[navs.length - 1].innerHTML;
++ assert.strictEqual((live.match(/data-chip-id="coins"/g) || []).length, 1);
++ assert.match(live, /data-chip-id="coins"[^>]*>[\s\S]*?>9</);
++ console.log('navbar-wallet-chips: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });