openvibers/openvibe.shared Commits
4.17.0: wallet: true puts OpenCoins and Vibes in the bar on any site (#139)
Changes
@@@ -4,6 +4,18 @@ 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.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@@@ -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 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.@@@ -650,6 +650,15 @@ Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,chip the first time and re-authenticates silently afterwards, 'silent' onlyre-authenticates, false opts out; fedcmLogin is the POST target for theassertion (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 theprevious render's document and window listeners and its panels.jsregistrations, so the old bar can be collected.@@@ -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)",@@@ -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;}@@@ -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"@@@ -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": {@@@ -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 ──────────────────────────{nnew file mode 100644@@@ -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); });
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 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
- **Balance chips fold into one on a phone** (`navbar.js`; owner, 2026-10-27: "the currencies are taking up all the
@@ -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.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
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.
@@ -650,6 +650,15 @@ Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,
@@ -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
chip the first time and re-authenticates silently afterwards, 'silent' only
re-authenticates, false opts out; fedcmLogin is the POST target for the
re-authenticates, false opts out; fedcmLogin is the POST target for the
assertion (default: this site's /auth/fedcm).
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
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
previous render's document and window listeners and its panels.js
registrations, so the old bar can be collected.
registrations, so the old bar can be collected.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.16.0",
+ "version": "4.17.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)",
@@ -1552,7 +1552,69 @@
@@ -1552,7 +1552,69 @@
document.head.appendChild(sc);
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() {
function render() {
+ syncWalletChips();
// A site may put state classes on the bar (Live's transparent hero mode). A re-render must not lose them.
// 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 = []; }
let carried = []; try { carried = Array.from((_navEl && _navEl.classList) || []).filter(c => c !== 'openvibe-navbar'); } catch { carried = []; }
if (_navEl) _navEl.remove();
if (_navEl) _navEl.remove();
@@ -1743,6 +1805,7 @@
@@ -1743,6 +1805,7 @@
_navEl = nav;
_navEl = nav;
attachAvatarFallbacks(nav);
attachAvatarFallbacks(nav);
hookTop();
hookTop();
+ loadWalletChips();
return nav;
return nav;
}
}
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.16.0",
+ "version": "4.17.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.16.0",
+ "version": "4.17.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.16.0",
+ "version": "4.17.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": {
@@ -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 = 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 ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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); });