openvibers/openvibe.shared Commits

openvibe-shared 4.222.0: the wallet sheet redesigned, every phone page back to its true width, whole site names in the menu (#497)

OP OpenVibers committed 23 h ago · d3ec549542 · 13 files, +251 −52

* Wallet: balances first, What changed as rows with their marks, every section headed, a designed empty state, side by side on a computer

* The bar never widens a phone page: its sides clip (Sign in reached 8 px past the edge; every page was 392 px at 390)

* The menu keeps a site's whole name: a long one takes a second line instead of an ellipsis

* Merge main (4.221.0) into the wallet sheet

* openvibe-shared 4.222.0: the wallet sheet redesigned, every phone page back to its true width, whole site names in the menu

* Sign in: taps stop at the bar edge, so the clip no longer cuts them

The bar clips sideways since 4.222.0 (a tap area past its edge widened every phone page).
Sign in's ::after reached 8px right, 2px past the bar's 6px padding, so at 320px the clip
left it 43px wide. It now reaches the edge exactly and 1px further left: 44px or more at
every width, nothing past the bar.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 05cc9bb..319abfd 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,34 @@
# Changelog
++## 4.222.0 — 2026-11-12
++
++The wallet sheet redesigned for phone and desktop, every phone page back to its true width, and whole site names in the
++menu (the owner, 11-12, a phone screenshot of the wallet marked "UGLY" twice; "make everything a little more
++user-friendly and elegant … perfect the layout … across all sites").
++
++- **The wallet:**
++ - **Balances first.**
++ - **What changed reads as rows:** one per currency, with its mark, a counted summary ("Quests done ×10") and when, and
++ the amount in its colour ("+161k"). Each change, with its own amount, sits in a fold ("See all 10"). Before, every
++ quest was strung into one paragraph.
++ - **Every section has a head with its glyph:** What changed, Recent activity, Store, Your items.
++ - **Empty parts are a small card with their mark** saying what will appear (openvibe.design rule 141), never a grey
++ line under a heading.
++ - **Recent activity in the same hand:** the currency's mark, the reason as a 44 px link (no underline across the row),
++ its first letter up, the amount in its colour.
++ - **Store prices are pills with the currency's mark**, each named in full for a screen reader; "Owned" is a green
++ badge; pictures and names sit closer.
++ - **On a computer, What changed and Recent activity sit side by side.**
++ - The head is the wallet's mark, "Wallet" and "Yours on every OpenVibe site".
++ - The sheet's marks are the registry's glyphs (`test/wallet-glyphs.test.js`).
++- **The bar never widens a phone page.** Every OpenVibe page was 392 px wide on a 390 px phone (so phones zoomed out a
++ little): Sign in's larger tap area reached 8 px past the bar's edge. `.openvibe-navbar { overflow-x: clip }`;
++ dropdowns hang below, so only the sides clip.
++- **The menu keeps a site's whole name:** a long one takes a second line instead of "Downlo…".
++
++Tests: `wallet-chrome` and `purse-catchup-chrome` (the new rows), `wallet-glyphs` (new); the wallet fixture can give a
++rich history (`changes: 'many'`).
++
## 4.221.0 — 2026-11-12
The icon system, audited end to end (plan L32 I1/I2; the owner, 11-12: "Broken icon. Improve the icons that exist, add
ddiff --git a/README.md b/README.md
iindex 2e2d689..816dd3e 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.221.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.222.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 a3b80d4..96469b3 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.221.0",
++ "version": "4.222.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 48e976b..5b09c04 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -34,6 +34,8 @@
/* At the very top the bar fades into the page (no background, no border); a scroll brings the frosted bar back.
A page whose hero runs under the bar opts in with body.ov-nav-overlay: the bar then sits over the hero, with a
soft scrim for the text. ovnav-top is kept by the navbar itself on every render and scroll. */
++ /* A tap area past the edge made pages 392 px at 390 (4.222.0). */
++ .openvibe-navbar { overflow-x: clip; }
.openvibe-navbar { transition: background-color .45s cubic-bezier(.2,.7,.2,1), border-color .45s cubic-bezier(.2,.7,.2,1), box-shadow .45s cubic-bezier(.2,.7,.2,1), -webkit-backdrop-filter .45s, backdrop-filter .45s, transform .32s cubic-bezier(.2,.7,.2,1); }
/* On a phone the bar steps away while you read down the page and comes back the moment you scroll up (4.25.0;
the owner, 2026-10-28: the bar's at-the-top effect "doesn't work on mobile… the whole thing needs to be
@@@ -106,8 +108,8 @@
.openvibe-navbar .openvibe-navbar-brand .flame { position: relative; }
.openvibe-navbar .openvibe-navbar-brand .flame::after { content: ''; position: absolute; inset: -8px -4px -8px -12px; }
.openvibe-navbar .openvibe-navbar-login, .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-orb { position: relative; }
-- /* Sign in is a 36px circle: its taps grow to the right and a little to the left, off the menu button's. */
-- .openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -5px -8px -5px -3px; }
++ /* Sign in's taps stop at the bar's edge (it clips) and go a little left, off the menu's. */
++ .openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -5px -6px -5px -4px; }
.openvibe-navbar .ovnav-burger::after { content: ''; position: absolute; inset: -3px -5px; }
/* The orb grows up and down, and sideways mostly to the right: the site's name sits 8px to its left and keeps
its own taps (4px there); the extra width goes to the open bar on its right (12px), so it measures 44px
@@@ -602,7 +604,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'c1f8f8dfab77', 'dock.js': '328a715e0ecd', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '1dd1ca512625', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'cc406790c229', 'dock.js': '328a715e0ecd', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '1dd1ca512625', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
ddiff --git a/ov-wallet.css b/ov-wallet.css
iindex 1fa0aa6..52a7575 100644
---- a/ov-wallet.css
++++ b/ov-wallet.css
@@@ -55,3 +55,62 @@ html[data-ov-motion=off] .ov-wallet-body.is-first > *, html[data-ov-motion=off]
@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ov-wallet-body.is-first > * { animation: ov-wallet-fade .2s ease both; animation-delay: 0s; } }
@keyframes ov-wallet-fade { from { opacity: 0; } }
.ov-wallet.ov-wallet-in{width:auto;max-height:none;border:0;border-radius:0;box-shadow:none;overflow:visible}.ovw:has(.ov-wallet-in){background:none;border:0}.ov-wallet-in{border-radius:inherit}.ov-wallet-in .ov-wallet-head{padding-right:64px}.ovw.is-sheet .ov-wallet-in .ov-wallet-head{padding-top:26px}.ov-wallet-in .ov-wallet-body{max-height:none;overflow:visible}.ovw-b:has(>.ov-wallet-in){padding:0}
++
++/* 4.221.0 (the owner, 11-12: "UGLY" on What changed and Store; "major design and stylistic improvements across both
++ mobile and desktop … nice looking icons"): the head says what this is; balances first; What changed is one row per
++ currency (its icon, a short summary, the amount in its colour, every move in a fold); each section has its mark; an
++ empty part is a small card with its mark; on a computer What changed and Recent activity sit side by side. */
++.ov-wallet-head{justify-content:flex-start;gap:14px;padding:18px 24px}
++.ov-wallet-mark{flex:none;display:grid;place-items:center;width:44px;height:44px;border-radius:14px;color:#fff;background:linear-gradient(135deg,color-mix(in srgb,var(--accent,#3b82f6) 88%,#22c55e),color-mix(in srgb,#22c55e 70%,var(--accent,#3b82f6)));box-shadow:0 6px 18px color-mix(in srgb,var(--accent,#3b82f6) 26%,transparent)}
++.ov-wallet-mark svg{width:24px;height:24px}
++.ov-wallet-name{min-width:0;display:grid;gap:1px}.ov-wallet-name h2{font-size:22px;letter-spacing:-.4px}.ov-wallet-name small{color:var(--wallet-muted);font-size:13px}
++.ov-wallet-head .ov-wallet-close{margin-left:auto}
++.ov-wallet-glyph{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;color:var(--accent-light,var(--accent,#60a5fa));background:color-mix(in srgb,var(--accent,#3b82f6) 13%,transparent)}
++.ov-wallet-glyph svg{width:19px;height:19px}
++.ov-wallet-section{margin-top:26px;min-width:0}
++.ov-wallet-sh{display:flex;align-items:center;gap:12px;margin-bottom:12px}
++.ov-wallet-sh h3{font-size:17px;letter-spacing:-.2px}.ov-wallet-sh .ov-wallet-muted{margin:1px 0 0}
++.ov-wallet-section>.ov-wallet-sh+p{margin-top:0}
++.ov-wallet-pair{display:grid;grid-template-columns:minmax(0,1fr);gap:0 18px}
++@media(min-width:760px){.ov-wallet-pair{grid-template-columns:repeat(2,minmax(0,1fr))}.ov-wallet-pair>.ov-wallet-section:only-child{grid-column:1/-1}}
++/* What changed */
++.ov-wallet-change{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:4px 12px;padding:10px 12px;margin-bottom:8px;border:1px solid var(--wallet-line);border-radius:14px;background:var(--wallet-card)}
++.ov-wallet-change-ic{display:grid;place-items:center;width:40px;height:40px;border-radius:12px;background:color-mix(in srgb,var(--pc,var(--accent,#3b82f6)) 16%,transparent)}
++.ov-wallet-change-ic svg{width:24px;height:24px}
++.ov-wallet-change-t{min-width:0;display:grid;gap:1px}.ov-wallet-change-t strong{font-size:14.5px}
++.ov-wallet-change-t small{color:var(--wallet-muted);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.ov-wallet-change-n{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums;letter-spacing:-.2px}
++.ov-wallet-change-n.is-gain{color:var(--success,#22a06b)}.ov-wallet-change-n.is-loss{color:var(--danger,#d14343)}
++.ov-wallet-change-more{grid-column:1/-1;margin:2px 0 0}
++.ov-wallet-change-more summary{display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:0 4px;color:var(--accent-light,var(--accent,#60a5fa));font-size:13px;font-weight:650;cursor:pointer;list-style:none}
++.ov-wallet-change-more summary::-webkit-details-marker{display:none}
++.ov-wallet-chev{display:inline-grid;transition:transform .2s}.ov-wallet-chev svg{width:13px;height:13px;transform:rotate(90deg)}
++.ov-wallet-change-more[open] .ov-wallet-chev{transform:rotate(180deg)}
++.ov-wallet-change-more ul{list-style:none;margin:4px 0 2px;padding:0;display:grid;gap:4px}
++.ov-wallet-change-more li{display:flex;align-items:center;gap:10px;min-height:36px;padding:4px 8px;border-radius:10px;background:color-mix(in srgb,var(--wallet-text) 4%,transparent);font-size:13.5px}
++.ov-wallet-change-more li>span:not(.ov-wallet-glyph){flex:1;min-width:0}.ov-wallet-change-more li b{font-variant-numeric:tabular-nums;font-size:13px}.ov-wallet-change-more li b.is-gain{color:var(--success,#22a06b)}.ov-wallet-change-more li b.is-loss{color:var(--danger,#d14343)}
++.ov-wallet-change-more li .ov-wallet-glyph{width:26px;height:26px;border-radius:8px}.ov-wallet-change-more li .ov-wallet-glyph svg{width:15px;height:15px}
++/* Recent activity, as rows in the same hand */
++.ov-wallet-pair .ov-wallet-event{padding:10px 2px}
++/* Empty, said with its mark */
++.ov-wallet-empty{grid-column:1/-1;display:flex;align-items:center;gap:14px;padding:14px 16px;border:1px dashed color-mix(in srgb,var(--wallet-muted) 45%,transparent);border-radius:14px;background:color-mix(in srgb,var(--wallet-text) 2%,transparent)}
++.ov-wallet-empty>div{display:grid;gap:2px;min-width:0}.ov-wallet-empty strong{font-size:14.5px}.ov-wallet-empty span:not(.ov-wallet-glyph){color:var(--wallet-muted);font-size:13px}
++@media(max-width:600px){.ov-wallet-head{padding:14px 18px 12px}.ov-wallet-mark{width:40px;height:40px;border-radius:12px}.ov-wallet-name h2{font-size:20px}.ov-wallet-section{margin-top:22px}.ov-wallet-change{padding:9px 10px;gap:2px 10px}.ov-wallet-change-ic{width:36px;height:36px}}
++html[data-ov-motion=off] .ov-wallet-chev{transition:none}
++/* Recent activity in the same hand as What changed */
++.ov-wallet-act{display:grid!important;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:2px 12px;padding:10px 12px!important;margin-bottom:8px;border:1px solid var(--wallet-line)!important;border-radius:14px;background:var(--wallet-card)}
++.ov-wallet-act .ov-wallet-change-ic{width:36px;height:36px}.ov-wallet-act .ov-wallet-change-ic svg{width:20px;height:20px}
++.ov-wallet-act-t{min-width:0;display:grid;gap:1px;font-size:14px}.ov-wallet-act-t strong{display:block;font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.ov-wallet-act-t>a{display:grid!important;align-content:center;gap:1px;min-height:44px!important;border:0!important;min-width:0}
++.ov-wallet-act-t strong::first-letter{text-transform:uppercase}
++.ov-wallet-act-t>a{color:inherit;text-decoration:none}.ov-wallet-act-t>a:hover strong{color:var(--accent-light,var(--accent,#60a5fa))}
++.ov-wallet-act-n{font-weight:750;font-size:13.5px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;max-width:none!important;overflow-wrap:normal!important}.ov-wallet-act-n.is-gain{color:var(--success,#22a06b)}.ov-wallet-act-n.is-loss{color:var(--wallet-text)}
++/* Store: a smaller picture, the name and its maker close together, the prices as pills with their currency's mark */
++.ov-wallet-item{gap:4px;padding:8px 8px 10px}.ov-wallet-item .ov-wallet-preview{height:88px;margin-bottom:4px}.ov-wallet-item h4{font-size:14.5px;padding:0 2px}.ov-wallet-item>.ov-wallet-muted{font-size:12.5px;padding:0 2px}
++.ov-wallet-prices{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
++.ov-wallet-price{display:inline-flex;align-items:center;gap:6px;min-height:44px;padding:0 12px 0 8px;border-radius:999px;border:1px solid var(--wallet-line);background:color-mix(in srgb,var(--wallet-text) 4%,transparent);color:var(--wallet-text);font:inherit;font-size:13.5px;font-weight:750;font-variant-numeric:tabular-nums;cursor:pointer}
++.ov-wallet-price:hover:not(:disabled){border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}
++.ov-wallet-price-ic{display:grid;place-items:center;width:22px;height:22px}.ov-wallet-price-ic svg{width:18px;height:18px}
++.ov-wallet-owned{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:0 12px 0 6px!important;border-radius:999px;font-weight:650;color:var(--success,#22a06b)!important;background:color-mix(in srgb,var(--success,#22a06b) 12%,transparent)}
++.ov-wallet-tick{display:grid;place-items:center;width:22px;height:22px}.ov-wallet-tick svg{width:15px;height:15px}
++@media(pointer:coarse){.ov-wallet-price{min-height:44px}}
ddiff --git a/ov-wallet.js b/ov-wallet.js
iindex 3d8b43e..a89ee5e 100644
---- a/ov-wallet.js
++++ b/ov-wallet.js
@@@ -8,6 +8,45 @@
return b.currency === 'USD' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value) : value.toLocaleString();
}
function close() { if (active) active.close(); }
++ // 4.221.0: the sheet's marks are the network's glyphs (openvibe-shared/glyphs, openvibe.design/icons);
++ // test/wallet-glyphs.test.js keeps them equal to the registry.
++ const GLYPHS = {
++ wallet: '<path d="M3.5 6.8A2.7 2.7 0 0 1 6.2 4.2H18.4v4.2M3.5 6.8V18a2.1 2.1 0 0 0 2.1 2.1h14.9V8.4H6.2A2.7 2.7 0 0 1 3.5 6.8Z"/><path d="M16.2 14.2h0"/>',
++ history: '<path d="M3.8 12.1a8.3 8.3 0 1 0 2.5-5.9L3.8 8.5M3.8 4.2v4.2h4.2"/><path d="M12 7.9v4.3l3.1 1.9"/>',
++ store: '<path d="M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1"/>',
++ gift: '<rect x="4.4" y="8.8" width="15.1" height="10.4" rx="0.9"/><path d="M3.5 8.8h17M12 8.8v10.4M12 8.8c-1.4-2.8-4.7-3.8-4.7-1.4S12 8.8 12 8.8s4.7-0.9 4.7-3.3S13.4 6 12 8.8"/>',
++ list: '<path d="M8.2 5.5h11.9M8.2 12h11.9M8.2 18.5h11.9M3.3 5.5h0M3.3 12h0M3.3 18.5h0"/>',
++ trophy: '<path d="M7.9 4h8.4v5.2a4.1 4.1 0 0 1-8.4 0ZM7.9 6.1H4.7a3.2 3.2 0 0 0 3.2 4.1M16.2 6.1h3.2a3.2 3.2 0 0 1-3.2 4.1M12 13.4v4.1M7.9 20.8h8.4"/>',
++ check: '<path d="M3.7 12.1l5.4 5.4L20.4 6.1"/>',
++ chevron: '<path d="M7.9 3.6l8.4 8.4l-8.4 8.4"/>',
++ };
++ const glyph = (name, cls) => { const t = el('span', cls || 'ov-wallet-glyph'); t.setAttribute('aria-hidden', 'true'); t.innerHTML = `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${GLYPHS[name] || ''}</svg>`; return t; };
++ /** "3 min ago", "2 h ago", "yesterday", or the date. */
++ function ago(at) {
++ const t = Date.parse(at || ''); if (!Number.isFinite(t)) return '';
++ const m = Math.max(0, Math.round((Date.now() - t) / 60000));
++ if (m < 1) return 'just now'; if (m < 60) return `${m} min ago`;
++ const h = Math.round(m / 60); if (h < 24) return `${h} h ago`;
++ return h < 48 ? 'yesterday' : new Date(t).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
++ }
++ /**
++ * A currency's recent moves, grouped so they read as a list, not a paragraph: every "for the quest …" is one group
++ * (Quests done ×10, each quest named in the fold); any other reason is its own row, counted when it repeats.
++ */
++ function groupMoves(moves) {
++ const groups = new Map();
++ for (const m of moves) {
++ const label = String((m && m.label) || '').replace(/\s+/g, ' ').trim(); if (!label) continue;
++ const quest = /^for the quest\s+(.+)$/i.exec(label);
++ const key = quest ? 'quest' : label.toLowerCase();
++ const g = groups.get(key) || { key, title: quest ? 'Quests done' : label.charAt(0).toUpperCase() + label.slice(1), icon: quest ? 'trophy' : null, items: [], n: 0, delta: 0, at: '' };
++ const d = Number(m.delta); if (Number.isFinite(d)) g.delta += d;
++ if (quest) g.items.push({ text: quest[1], delta: Number.isFinite(d) ? d : null });
++ g.n += 1; if (String(m.at || '') > g.at) g.at = String(m.at || '');
++ groups.set(key, g);
++ }
++ return [...groups.values()].sort((a, b) => b.at.localeCompare(a.at));
++ }
/**
* A hosted checkout is followed only to a host Billing names for the chosen provider (checkout.providers:
* [{ name, hosts }]) or to Billing itself. Anything else — http, a URL with a username, a foreign host — is refused.
@@@ -60,9 +99,10 @@
let win = null;
const dialog = W ? el('div', 'ov-wallet ov-wallet-in') : el('dialog', 'ov-wallet');
dialog.setAttribute('aria-labelledby', 'ov-wallet-title');
-- const header = el('header', 'ov-wallet-head'), heading = el('div');
-- heading.append(el('h2', '', 'Wallet'));
-- heading.lastChild.id = 'ov-wallet-title';
++ const header = el('header', 'ov-wallet-head'), heading = el('div', 'ov-wallet-name');
++ const title = el('h2', '', 'Wallet'); title.id = 'ov-wallet-title';
++ heading.append(title, el('small', '', 'Yours on every OpenVibe site'));
++ header.append(glyph('wallet', 'ov-wallet-mark'));
const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
header.append(heading, exit);
const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', '');
@@@ -113,9 +153,14 @@
try { identity(); await action(); } catch (e) { message(e); } finally { if (b.isConnected) b.disabled = false; }
}); return b;
}
-- function section(title, description) {
-- const s = el('section', 'ov-wallet-section'); s.append(el('h3', '', title));
-- if (description) s.append(el('p', 'ov-wallet-muted', description)); body.append(s); return s;
++ function section(title, description, icon, into) {
++ const s = el('section', 'ov-wallet-section'), head = el('div', 'ov-wallet-sh'), words = el('div');
++ words.append(el('h3', '', title)); if (description) words.append(el('p', 'ov-wallet-muted', description));
++ if (icon) head.append(glyph(icon)); head.append(words); s.append(head); (into || body).append(s); return s;
++ }
++ /** Nothing here yet, said as a small card with its mark (never two grey lines under a heading). */
++ function empty(icon, line, sub) {
++ const e = el('div', 'ov-wallet-empty'); e.append(glyph(icon)); const w = el('div'); w.append(el('strong', '', line)); if (sub) w.append(el('span', '', sub)); e.append(w); return e;
}
function preview(art, name) {
const p = el('div', 'ov-wallet-preview');
@@@ -185,33 +230,53 @@
status.classList.remove('is-error');
// The bar keeps the last window in this tab after marking it read. A direct sheet read supplies it when
// the bar has not yet fetched changes. Reasons are the API's plain labels, never ledger keys.
++ const balances = el('div', 'ov-wallet-balances'); body.append(balances);
++ const pair = el('div', 'ov-wallet-pair');
const changeWindow = (root.OpenVibePurse && root.OpenVibePurse.recentChanges()) || changeData;
++ const changes = [];
if (changeWindow) {
-- const entries = [['opencoins', 'OpenCoins'], ['vibes', 'Vibes'], ['channel_points', 'Channel points']]
-- .map(([key, name]) => ({ key, name, part: changeWindow[key] }))
-- .filter(({ part }) => part && Number.isSafeInteger(Number(part.net)) && Number(part.net) !== 0)
-- .sort((a, b) => String((b.part.moves || []).at(-1)?.at || '').localeCompare(String((a.part.moves || []).at(-1)?.at || '')));
-- if (entries.length) {
-- const section = el('section', 'ov-wallet-section ov-wallet-changes');
-- section.append(el('h3', '', 'What changed'));
-- let remaining = 10;
-- for (const { name, part } of entries) {
-- const row = el('div', 'ov-wallet-event'), info = el('div');
-- info.append(el('strong', '', name));
-- const moves = Array.isArray(part.moves) ? [...part.moves].sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))) : [];
-- const reasons = moves.slice(0, remaining).map((move) => String(move.label || '')).filter(Boolean);
-- remaining -= reasons.length;
-- if (reasons.length) info.append(el('small', '', reasons.join(' · ')));
-- const n = Number(part.net), full = `${n > 0 ? '+' : '−'}${Math.abs(n).toLocaleString().replace(/,/g, '\u2009')}`;
-- const compact = Math.abs(n) > 10000 ? `${n > 0 ? '+' : '−'}${Math.round(Math.abs(n) / 1000)}k` : full;
-- const value = el('span', n > 0 ? 'is-gain' : 'is-loss', compact);
-- value.style.fontVariantNumeric = 'tabular-nums'; value.style.color = n > 0 ? 'var(--success, #17865a)' : 'var(--danger, #c84141)';
-- value.title = full; value.setAttribute('aria-label', full); row.append(info, value); section.append(row);
++ for (const [key, id, name] of [['opencoins', 'coins', 'OpenCoins'], ['vibes', 'vibes', 'Vibes'], ['channel_points', 'channel-points', 'Channel points']]) {
++ const part = changeWindow[key];
++ if (part && Number.isSafeInteger(Number(part.net)) && Number(part.net) !== 0) changes.push({ id, name, part });
++ }
++ changes.sort((a, b) => String((b.part.moves || []).at(-1)?.at || '').localeCompare(String((a.part.moves || []).at(-1)?.at || '')));
++ }
++ if (changes.length) {
++ const sec = section('What changed', 'Since you last looked.', 'history', pair);
++ sec.classList.add('ov-wallet-changes');
++ for (const { id, name, part } of changes) {
++ const row = el('div', 'ov-wallet-change'), info = el('div', 'ov-wallet-change-t');
++ const purse = root.OpenVibePurse || null;
++ const mark = el('span', 'ov-wallet-change-ic'); if (purse && purse.icon) { mark.innerHTML = purse.icon(id === 'channel-points' ? 'coins' : id, 22); mark.setAttribute('data-purse', id === 'channel-points' ? 'coins' : id); } else mark.append(glyph('wallet'));
++ mark.setAttribute('aria-hidden', 'true');
++ const moves = Array.isArray(part.moves) ? [...part.moves].sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))) : [];
++ const groups = groupMoves(moves);
++ info.append(el('strong', '', name));
++ const line = groups.slice(0, 2).map((g) => (g.n > 1 ? `${g.title} ×${g.n}` : g.title)).join(' · ') + (groups.length > 2 ? ` · ${groups.length - 2} more` : '');
++ const when = ago(moves[0] && moves[0].at);
++ if (line || when) info.append(el('small', '', [line, when].filter(Boolean).join(' · ')));
++ const n = Number(part.net), full = `${n > 0 ? '+' : '−'}${Math.abs(n).toLocaleString().replace(/,/g, '\u2009')}`;
++ const compact = Math.abs(n) > 10000 ? `${n > 0 ? '+' : '−'}${Math.round(Math.abs(n) / 1000)}k` : full;
++ const value = el('span', `ov-wallet-change-n ${n > 0 ? 'is-gain' : 'is-loss'}`, compact);
++ value.style.fontVariantNumeric = 'tabular-nums'; value.title = full; value.setAttribute('aria-label', full);
++ row.append(mark, info, value);
++ // Each move, in a fold: the quests by name, the rest as they were said.
++ if (moves.length > 1 || groups.some((g) => g.items.length)) {
++ const more = el('details', 'ov-wallet-change-more'), sum = el('summary');
++ sum.append(el('span', '', `See all ${moves.length}`), glyph('chevron', 'ov-wallet-chev'));
++ const list = el('ul');
++ const signed = (v) => (v == null || !v ? null : el('b', v > 0 ? 'is-gain' : 'is-loss', `${v > 0 ? '+' : '−'}${Math.abs(v).toLocaleString()}`));
++ const item = (icon, text, v) => { const li = el('li'); li.append(glyph(icon), el('span', '', text)); const b = signed(v); if (b) li.append(b); list.append(li); };
++ for (const g of groups) {
++ if (g.items.length) for (const it of g.items) item(g.icon || 'list', it.text, it.delta);
++ else item('list', g.n > 1 ? `${g.title} ×${g.n}` : g.title, g.delta);
++ }
++ more.append(sum, list); row.append(more);
}
-- body.append(section);
++ sec.append(row);
}
}
-- const balances = el('div', 'ov-wallet-balances'); body.append(balances);
++ body.append(pair);
for (const id of ['coins', 'vibes', 'balance']) {
const b = data.balances[id], card = el('article', `ov-wallet-balance is-${id}`);
const numeric = Number(b.amount) / Math.pow(10, b.scale || 0);
@@@ -235,29 +300,36 @@
else if (was !== numeric) { purse.count(value, was, numeric, words); card.classList.add(numeric > was ? 'is-gain' : 'is-loss'); setTimeout(() => { if (card.isConnected) purse.burst(card, id, numeric - was); }, 60); }
}
}
-- const store = section('Store', 'A little more you. Items travel with your OpenVibe account.');
++ const store = section('Store', 'A little more you. Items travel with your OpenVibe account.', 'store');
const grid = el('div', 'ov-wallet-grid'); store.append(grid);
if (!here.store) grid.append(wait(2, 'is-tile'));
-- else if (!storeItems.length) grid.append(el('p', 'ov-wallet-muted', 'New items will appear here after review.'));
++ else if (!storeItems.length) grid.append(empty('store', 'New items are on their way', 'They appear here once they pass review.'));
for (const item of storeItems) {
const card = el('article', 'ov-wallet-item'); card.append(preview(item.preview, item.name), el('h4', '', item.name), el('p', 'ov-wallet-muted', `By ${maker(item.issuer)}`));
const owned = item.owned || (allItems && allItems.instances.some((i) => i.definition_id === item.id && i.state === 'owned'));
-- if (owned) card.append(el('span', 'ov-wallet-owned', '✓ Owned'));
++ const prices = el('div', 'ov-wallet-prices');
++ if (owned) { const o = el('span', 'ov-wallet-owned', 'Owned'); o.prepend(glyph('check', 'ov-wallet-tick')); prices.append(o); }
else for (const [currency, price] of Object.entries(item.prices)) {
-- const buy = button(`Buy · ${price.toLocaleString()} ${currency === 'vibes' ? 'Vibes' : 'OpenCoins'}`, async () => {
++ const word = currency === 'vibes' ? 'Vibes' : 'OpenCoins';
++ const buy = button(`Buy · ${price.toLocaleString()} ${word}`, async () => {
let attempt = pending.get(item.id);
if (!attempt) { attempt = { currency, idempotency_key: root.crypto.randomUUID() }; pending.set(item.id, attempt); }
if (attempt.currency !== currency) throw new Error('Retry this item in the currency you chose first.');
await request(`/api/v1/store/${encodeURIComponent(item.id)}/buy`, 'POST', attempt);
await Promise.all([refreshWallet(), refreshItems()]); render();
-- }); buy.disabled = data.money_writes_frozen || !item.available; card.append(buy);
++ }, 'ov-wallet-price'); buy.disabled = data.money_writes_frozen || !item.available;
++ buy.setAttribute('aria-label', `Buy ${item.name} for ${price.toLocaleString()} ${word}`); buy.title = `Buy for ${price.toLocaleString()} ${word}`;
++ const purse = root.OpenVibePurse; buy.textContent = '';
++ const ic = el('span', 'ov-wallet-price-ic'); ic.setAttribute('aria-hidden', 'true'); if (purse && purse.icon) { ic.innerHTML = purse.icon(currency === 'vibes' ? 'vibes' : 'coins', 16); ic.setAttribute('data-purse', currency === 'vibes' ? 'vibes' : 'coins'); } else ic.textContent = currency === 'vibes' ? 'V' : 'O';
++ buy.append(ic, el('span', '', price.toLocaleString())); prices.append(buy);
}
++ card.append(prices);
grid.append(card);
}
-- const mine = section('Your items', 'Wear them wherever their kind is supported.');
++ const mine = section('Your items', 'Wear them wherever their kind is supported.', 'gift');
const owned = allItems ? allItems.instances.filter((i) => i.state === 'owned') : [];
if (!here.items) mine.append(wait(2, 'is-row'));
-- else if (!owned.length) mine.append(el('p', 'ov-wallet-muted', 'Your collection starts here.'));
++ else if (!owned.length) mine.append(empty('gift', 'Your collection starts here', 'What you get or earn shows up here, ready to wear.'));
for (const item of owned) {
const def = allItems.definitions[item.definition_id]; if (!def) continue;
const kind = kinds.find((k) => k.id === def.kind), row = el('article', 'ov-wallet-owned-row'), copy = el('div');
@@@ -277,20 +349,22 @@
const page = await request('/api/v1/me/items?limit=100&cursor=' + encodeURIComponent(allItems.next_cursor));
allItems = { ...page, instances: allItems.instances.concat(page.instances), definitions: { ...allItems.definitions, ...page.definitions } }; render();
}));
-- const recent = section('Recent activity', 'Your last five money events.');
-- if (!data.recent_activity.length) recent.append(el('p', 'ov-wallet-muted', 'No activity yet.'));
++ const recent = section('Recent activity', 'Your last five money events.', 'list', pair);
++ if (!data.recent_activity.length) recent.append(empty('list', 'No activity yet', 'Purchases, top-ups and tips show up here.'));
for (const event of data.recent_activity) {
// The reason is a sentence made by billing.money-reason@1 (Billing or Network); when it names
// something with a page, the sentence is the link to it. The raw ledger key is never drawn.
-- const row = el('div', 'ov-wallet-event'), info = el('div');
++ const row = el('div', 'ov-wallet-event ov-wallet-act'), info = el('div', 'ov-wallet-act-t');
++ { const mk = el('span', 'ov-wallet-change-ic'); mk.setAttribute('aria-hidden', 'true'); const id = event.kind === 'balance' ? 'balance' : event.kind === 'vibes' ? 'vibes' : 'coins'; const purse = root.OpenVibePurse; if (purse && purse.icon) { mk.innerHTML = purse.icon(id, 20); mk.setAttribute('data-purse', id); } else mk.append(glyph('list')); row.append(mk); }
const url = event.subject && event.subject.url;
if (url && /^https:\/\//.test(url)) {
-- const a = el('a', '', event.label);
++ // The link is the reason and its line together: one target of a thumb's size, no underline across the row.
++ const a = el('a'); a.append(el('strong', '', event.label), el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`));
a.href = url; a.target = '_blank'; a.rel = 'noopener';
-- info.append(a, el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`));
++ info.append(a);
} else info.append(el('strong', '', event.label), el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`));
const units = event.kind === 'balance' ? { currency: 'USD', scale: 9 } : { currency: event.kind, scale: 0 };
-- row.append(info, el('span', '', `${Number(event.amount) > 0 ? '+' : ''}${amount({ ...units, amount: event.amount })}${event.kind === 'balance' ? '' : event.kind === 'vibes' ? ' Vibes' : ' OpenCoins'}`)); recent.append(row);
++ row.append(info, el('span', `ov-wallet-act-n ${Number(event.amount) > 0 ? 'is-gain' : 'is-loss'}`, `${Number(event.amount) > 0 ? '+' : ''}${amount({ ...units, amount: event.amount })}${event.kind === 'balance' ? '' : event.kind === 'vibes' ? ' Vibes' : ' OpenCoins'}`)); recent.append(row);
}
}
exit.addEventListener('click', session.close);
@@@ -322,7 +396,7 @@
} catch (error) { if (seed && !disposed) { here.store = here.items = true; } message(error); }
return session;
}
-- const api = { open, close, amount, checkoutAllowed };
++ const api = { open, close, amount, checkoutAllowed, GLYPHS, groupMoves };
root.OpenVibeWallet = api;
if (typeof module === 'object' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/package-lock.json b/package-lock.json
iindex fece9e7..f7b148a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.221.0",
++ "version": "4.222.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.221.0",
++ "version": "4.222.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 56255f0..3055965 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.221.0",
++ "version": "4.222.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/search.js b/search.js
iindex 400b0bb..1a24b6b 100644
---- a/search.js
++++ b/search.js
@@@ -290,6 +290,8 @@
hairline, so the list reads as sections and not as one long column of small rings. */
.ovs-grid.is-mini { gap: 4px; }
.ovs-grid.is-mini .ovs-tile { min-height: 48px; padding: 6px 8px; border-radius: 12px; transition: background-color .15s, transform .12s; }
++/* 4.222.0: a site's whole name, never "Downlo…" or "Commu…": a long name takes a second line instead of an ellipsis. */
++.ovs-grid.is-mini .ovs-tile b { white-space: normal; text-overflow: clip; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.15; overflow-wrap: anywhere; }
.ovs-grid.is-mini .ovs-tile:active { transform: scale(.97); }
.ovs-grid.is-mini .ovs-ic { width: 32px; height: 32px; border-radius: 10px; }
/* 4.202.0: on a phone a site's name sits under its mark, so the whole name shows (Download, Community: not "Downlo…"). */
ddiff --git a/test/helpers/wallet-fixture.js b/test/helpers/wallet-fixture.js
iindex 3403132..9239042 100644
---- a/test/helpers/wallet-fixture.js
++++ b/test/helpers/wallet-fixture.js
@@@ -38,6 +38,11 @@ function fixture({ changes = false, changeNet = 5 } = {}) {
if (pathname.startsWith('/changes') && changes) {
if (req.method === 'PUT' && pathname === '/changes/read') { readCursor = body.cursor; return json({ currency: body.currency, read_cursor: readCursor }); }
if (changes === 'unauthorized') return json({ error: 'Authentication required' }, 401);
++ if (changes === 'many') {
++ const quests = ['Publish a video', 'Share a picture', 'Say hello in chat', 'Play a game', 'Wear an item', 'Live setup slot', 'Live setup method', 'Live setup restream', 'Live setup profile', 'Live setup emote'];
++ const moves = quests.map((q, i) => ({ id: 100 + i, at: new Date(Date.now() - (i + 1) * 3600e3).toISOString(), delta: 16100, label: `for the quest ${q}` }));
++ return json({ opencoins: { currency: 'opencoins', net: 161000, cursor: 110, moves }, vibes: { currency: 'vibes', net: 500, cursor: 7, moves: [{ id: 7, at: new Date(Date.now() - 1800e3).toISOString(), delta: 500, label: 'bought with your card' }] }, channel_points: null });
++ }
return json({ opencoins: { currency: 'opencoins', net: readCursor ? 0 : changeNet, cursor: 42, moves: readCursor ? [] : [{ id: 42, at: '2026-11-11T12:00:00Z', delta: changeNet, label: 'For a quest' }] }, vibes: null, channel_points: null });
}
if (pathname === '/api/v1/me/wallet') return json(wallet);
ddiff --git a/test/purse-catchup-chrome.test.js b/test/purse-catchup-chrome.test.js
iindex 6f9dc4e..2996025 100644
---- a/test/purse-catchup-chrome.test.js
++++ b/test/purse-catchup-chrome.test.js
@@@ -34,7 +34,7 @@ if (!h.findChrome()) { console.log('purse catch-up Chrome: skipped (Chrome unava
if (mode === 'calm') assert.deepStrictEqual(await page.evaluate('window._pursePieces'), [], 'Calm has no flying pieces');
await page.evaluate("document.querySelector('[data-chip-id]').click()");
await eventually(async () => assert(await page.evaluate("document.querySelector('.ov-wallet-changes')?.innerText.includes('For a quest')")));
-- const line = await page.evaluate("(() => { const x=document.querySelector('.ov-wallet-changes .ov-wallet-event'); const v=x.querySelector('span'); return { text:x.innerText, title:v.title, aria:v.getAttribute('aria-label'), tabular:v.style.fontVariantNumeric }; })()");
++ const line = await page.evaluate("(() => { const x=document.querySelector('.ov-wallet-changes .ov-wallet-change'); const v=x.querySelector('.ov-wallet-change-n'); return { text:x.innerText, title:v.title, aria:v.getAttribute('aria-label'), tabular:v.style.fontVariantNumeric }; })()");
assert.match(line.text, /OpenCoins/); assert.match(line.text, mode === 'compact' ? /\+161k/ : /\+5/);
assert.strictEqual(line.title, mode === 'compact' ? '+160\u2009799' : '+5');
assert.strictEqual(line.aria, line.title); assert.strictEqual(line.tabular, 'tabular-nums');
ddiff --git a/test/wallet-chrome.test.js b/test/wallet-chrome.test.js
iindex 9668dae..8ec72fe 100644
---- a/test/wallet-chrome.test.js
++++ b/test/wallet-chrome.test.js
@@@ -24,7 +24,7 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&(d.open||d.closest('.ovw'))), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);
assert(metrics.open, 'first chip tap opens wallet'); assert.strictEqual(metrics.width, width); assert(metrics.inner <= metrics.box + 1);
assert.strictEqual(metrics.lock, 'hidden'); assert(metrics.text.includes('$0.00')); assert(metrics.text.includes('Buy Vibes')); assert(metrics.text.includes('Top up'));
-- assert(metrics.text.includes('for the quest Publish a video'), 'a money reason is a sentence, not a key');
++ assert(/for the quest Publish a video/i.test(metrics.text), 'a money reason is a sentence, not a key (4.221.0: drawn with its first letter up)');
assert(!/quest:publish|Daily quest completed/.test(metrics.text));
assert(await p.evaluate(`[...document.querySelectorAll('.ov-wallet-event a')].some(a=>a.getAttribute('href')==='https://openvibe.quest/quests/publish-a-video')`), 'the reason links to its subject');
assert(metrics.buttons.every((height) => height >= 44), '44px controls');
ddiff --git a/test/wallet-glyphs.test.js b/test/wallet-glyphs.test.js
nnew file mode 100644
iindex 0000000..a10abc0
---- /dev/null
++++ b/test/wallet-glyphs.test.js
@@@ -0,0 +1,28 @@
++'use strict';
++// The wallet sheet draws the network's glyphs, not marks of its own (Shared 4.221.0; the owner, 11-12: "nice looking
++// icons"): every mark in its set is the registry's drawing of the same name, so a
++// glyph redrawn on openvibe.design/icons is redrawn here when the file is rebuilt, and this test says which is stale.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const glyphs = require('../glyphs');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'ov-wallet.js'), 'utf8');
++// A document just real enough for the file to define itself.
++const sandbox = { document: { createElement: () => ({}), currentScript: null, getElementById: () => null }, console };
++sandbox.window = sandbox;
++vm.runInNewContext(src, sandbox, { filename: 'ov-wallet.js' });
++const set = sandbox.OpenVibeWallet && sandbox.OpenVibeWallet.GLYPHS;
++assert.ok(set && typeof set === 'object', 'ov-wallet.js exposes its marks as OpenVibeWallet.GLYPHS');
++const names = Object.keys(set);
++assert.ok(names.length >= 7, `the sheet's marks (${names.length})`);
++names.forEach((name) => {
++ assert.ok(glyphs.path(name), `${name} is a glyph in the registry (openvibe-shared/glyphs)`);
++ assert.strictEqual(set[name], glyphs.path(name), `${name} is drawn as the registry draws it`);
++});
++// Every mark the file draws (glyph('…'), empty('…'), a section's mark) is in the set (a typo would draw nothing).
++const used = [...new Set([...src.matchAll(/\b(?:glyph|empty)\('([a-z]+)'/g), ...src.matchAll(/\bsection\('[^']*', '[^']*', '([a-z]+)'/g)].map((m) => m[1]))];
++assert.ok(used.length >= 6, `the sheet's marks in use (${used.length})`);
++used.forEach((name) => assert.ok(names.includes(name), `glyph('${name}') is in the set`));
++console.log(`wallet-glyphs: ${names.length} marks, all the registry's; ${used.length} used`);