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)
Changes
@@@ -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-12The icon system, audited end to end (plan L32 I1/I2; the owner, 11-12: "Broken icon. Improve the icons that exist, add@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.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)",@@@ -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 asoft 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 keepsits 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.@@@ -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}}@@@ -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);@@@ -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",@@@ -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": {@@@ -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…"). */@@@ -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);@@@ -34,7 +34,7 @@ if (!h.findChrome()) { console.log('purse catch-up Chrome: skipped (Chrome unavaif (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');@@@ -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');nnew file mode 100644@@@ -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`);
@@ -1,5 +1,34 @@
@@ -1,5 +1,34 @@
# Changelog
# 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
## 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
The icon system, audited end to end (plan L32 I1/I2; the owner, 11-12: "Broken icon. Improve the icons that exist, add
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.221.0",
+ "version": "4.222.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)",
@@ -34,6 +34,8 @@
@@ -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.
/* 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
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. */
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); }
.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;
/* 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
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 @@
@@ -106,8 +108,8 @@
.openvibe-navbar .openvibe-navbar-brand .flame { position: relative; }
.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-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; }
.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; }
.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
/* 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
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 @@
@@ -602,7 +604,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* 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.
// 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]}` : ''}`;
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.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -55,3 +55,62 @@ html[data-ov-motion=off] .ov-wallet-body.is-first > *, html[data-ov-motion=off]
@@ -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; } }
@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; } }
@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}
.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}}
@@ -8,6 +8,45 @@
@@ -8,6 +8,45 @@
return b.currency === 'USD' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value) : value.toLocaleString();
return b.currency === 'USD' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value) : value.toLocaleString();
}
}
function close() { if (active) active.close(); }
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:
* 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.
* [{ name, hosts }]) or to Billing itself. Anything else — http, a URL with a username, a foreign host — is refused.
@@ -60,9 +99,10 @@
@@ -60,9 +99,10 @@
let win = null;
let win = null;
const dialog = W ? el('div', 'ov-wallet ov-wallet-in') : el('dialog', 'ov-wallet');
const dialog = W ? el('div', 'ov-wallet ov-wallet-in') : el('dialog', 'ov-wallet');
dialog.setAttribute('aria-labelledby', 'ov-wallet-title');
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');
const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
header.append(heading, exit);
header.append(heading, exit);
const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', '');
const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', '');
@@ -113,9 +153,14 @@
@@ -113,9 +153,14 @@
try { identity(); await action(); } catch (e) { message(e); } finally { if (b.isConnected) b.disabled = false; }
try { identity(); await action(); } catch (e) { message(e); } finally { if (b.isConnected) b.disabled = false; }
}); return b;
}); 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) {
function preview(art, name) {
const p = el('div', 'ov-wallet-preview');
const p = el('div', 'ov-wallet-preview');
@@ -185,33 +230,53 @@
@@ -185,33 +230,53 @@
status.classList.remove('is-error');
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 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.
// 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 changeWindow = (root.OpenVibePurse && root.OpenVibePurse.recentChanges()) || changeData;
+ const changes = [];
if (changeWindow) {
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']) {
for (const id of ['coins', 'vibes', 'balance']) {
const b = data.balances[id], card = el('article', `ov-wallet-balance is-${id}`);
const b = data.balances[id], card = el('article', `ov-wallet-balance is-${id}`);
const numeric = Number(b.amount) / Math.pow(10, b.scale || 0);
const numeric = Number(b.amount) / Math.pow(10, b.scale || 0);
@@ -235,29 +300,36 @@
@@ -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); }
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);
const grid = el('div', 'ov-wallet-grid'); store.append(grid);
if (!here.store) grid.append(wait(2, 'is-tile'));
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) {
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 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'));
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)) {
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);
let attempt = pending.get(item.id);
if (!attempt) { attempt = { currency, idempotency_key: root.crypto.randomUUID() }; pending.set(item.id, attempt); }
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.');
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 request(`/api/v1/store/${encodeURIComponent(item.id)}/buy`, 'POST', attempt);
await Promise.all([refreshWallet(), refreshItems()]); render();
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);
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') : [];
const owned = allItems ? allItems.instances.filter((i) => i.state === 'owned') : [];
if (!here.items) mine.append(wait(2, 'is-row'));
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) {
for (const item of owned) {
const def = allItems.definitions[item.definition_id]; if (!def) continue;
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');
const kind = kinds.find((k) => k.id === def.kind), row = el('article', 'ov-wallet-owned-row'), copy = el('div');
@@ -277,20 +349,22 @@
@@ -277,20 +349,22 @@
const page = await request('/api/v1/me/items?limit=100&cursor=' + encodeURIComponent(allItems.next_cursor));
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();
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) {
for (const event of data.recent_activity) {
// The reason is a sentence made by billing.money-reason@1 (Billing or Network); when it names
// 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.
// 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;
const url = event.subject && event.subject.url;
if (url && /^https:\/\//.test(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';
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()}`));
} 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 };
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);
exit.addEventListener('click', session.close);
@@ -322,7 +396,7 @@
@@ -322,7 +396,7 @@
} catch (error) { if (seed && !disposed) { here.store = here.items = true; } message(error); }
} catch (error) { if (seed && !disposed) { here.store = here.items = true; } message(error); }
return session;
return session;
}
}
- const api = { open, close, amount, checkoutAllowed };
+ const api = { open, close, amount, checkoutAllowed, GLYPHS, groupMoves };
root.OpenVibeWallet = api;
root.OpenVibeWallet = api;
if (typeof module === 'object' && module.exports) module.exports = api;
if (typeof module === 'object' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.221.0",
+ "version": "4.222.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.221.0",
+ "version": "4.222.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -290,6 +290,8 @@
@@ -290,6 +290,8 @@
hairline, so the list reads as sections and not as one long column of small rings. */
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 { gap: 4px; }
.ovs-grid.is-mini .ovs-tile { min-height: 48px; padding: 6px 8px; border-radius: 12px; transition: background-color .15s, transform .12s; }
.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-tile:active { transform: scale(.97); }
.ovs-grid.is-mini .ovs-ic { width: 32px; height: 32px; border-radius: 10px; }
.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…"). */
/* 4.202.0: on a phone a site's name sits under its mark, so the whole name shows (Download, Community: not "Downlo…"). */
@@ -38,6 +38,11 @@ function fixture({ changes = false, changeNet = 5 } = {}) {
@@ -38,6 +38,11 @@ function fixture({ changes = false, changeNet = 5 } = {}) {
if (pathname.startsWith('/changes') && changes) {
if (pathname.startsWith('/changes') && changes) {
if (req.method === 'PUT' && pathname === '/changes/read') { readCursor = body.cursor; return json({ currency: body.currency, read_cursor: readCursor }); }
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 === '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 });
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);
if (pathname === '/api/v1/me/wallet') return json(wallet);
@@ -34,7 +34,7 @@ if (!h.findChrome()) { console.log('purse catch-up Chrome: skipped (Chrome unava
@@ -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');
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 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')")));
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.match(line.text, /OpenCoins/); assert.match(line.text, mode === 'compact' ? /\+161k/ : /\+5/);
assert.strictEqual(line.title, mode === 'compact' ? '+160\u2009799' : '+5');
assert.strictEqual(line.title, mode === 'compact' ? '+160\u2009799' : '+5');
assert.strictEqual(line.aria, line.title); assert.strictEqual(line.tabular, 'tabular-nums');
assert.strictEqual(line.aria, line.title); assert.strictEqual(line.tabular, 'tabular-nums');
@@ -24,7 +24,7 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -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)} })()`);
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(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.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(!/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(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');
assert(metrics.buttons.every((height) => height >= 44), '44px controls');
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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`);