openvibers/openvibe.shared Commits

4.189.0: one pill for the balances, the menu and the Dock lead to each other, the menu opens on this site (#415)

OP OpenVibers committed 3 days ago · f20bcc090f · 18 files, +369 −63

* The menu opens where you are: your site first in its group, larger site tiles, group names on a hairline

* The menu and the Dock lead to each other: a row under the menu's tabs opens Vibe, Agents, Messages or Activity, and the Dock's head has the way back

* The balances are one pill that turns between what you have; the Dock head matches the menu; the menu opens on this site

purse.js: an icon drawn for each currency, a turn through the balances on the first page of a visit and now and then,
and a gain or spend effect when a balance changes. The Dock: its close button is drawn right, its chosen tab takes the
accent, and on a phone its head is two rows with a named way back to the menu. The menu: its button opens this site tab
(no tab is remembered), and the pages there are tiles under "Pages on <site>".

* 4.189.0: one pill for the balances, the menu and the Dock lead to each other, the menu opens on this site

* Sites serve purse.js (the list of browser files)

* The feeds check follows the new opening rule: the menu reopens on this site tab

* The menu-close check waits for the fold instead of a fixed 450 ms (a slow macOS runner)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 24a75af..f33c3d9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,36 @@
# Changelog
++## 4.189.0 — 2026-11-10
++
++**The balances are one pill, the menu and the Dock lead to each other, and the menu opens where you are.** Seven notes
++from the owner on 2026-11-10, each with a screenshot of the bar or the menu.
++
++- **One pill for the balances** ("we just show one, but it can animate between all three … it only needs to show the
++ things you have … nice icons that are animated, designed for the three currencies … a custom animation for gaining
++ and losing, with coins coming out of it"). `#openvibe-chips-all` is drawn at every width, with the balances the
++ person has (above zero, the largest first; the money balance leads when AI was used and it is empty) stacked in one
++ place and one showing. New `purse.js`: an icon drawn for OpenCoins (a coin struck with the ring), Vibes (a cut gem)
++ and the money balance (a note with a seal), each in its own colour leaned toward the theme's text so it holds on
++ light and dark; a turn through the balances on the first page of a visit and one every forty seconds or so; and on a
++ change the pill turns to that balance, counts the number, takes its colour for a moment and sends a few of its
++ pieces in (a gain) or out (a spend) with the amount under it. Calm motion shortens the moves; Off removes them.
++ The two-line pill on phones and the separate chips on computers are gone.
++- **The menu opens on this site's tab** ("this button should open the first tab so people can switch between pages on
++ the site they're on … make it more clear that those are pages on the site you're on"). No tab is remembered; the
++ picture still opens You. The pages are tiles under "Pages on <site>", in the case it is written in, behind a dot that
++ marks where you are.
++- **The current site is first in its group**, the site tiles are full touch targets, and a group's name sits on a
++ hairline ("design should be the first result … improve these section titles … make the icons look nicer").
++- **From the menu to the Dock and back** ("how are you supposed to get to the other menu from this area … a way to hop
++ over to the other interface that has the chat"). Under the menu's tabs a row opens the Dock on Vibe, Agents, Messages
++ or Activity (`ctx.dock`, given by the navbar where the Dock is on); the Dock's head has "Menu", which folds the Dock
++ and opens the menu out of the same button.
++- **The Dock's head** ("buggy and kind of ugly … more consistent, cohesive"). Its close button was drawn as a white bar
++ over blue dots: the Dock's line style for icons reached the menu button's parts. It is the same cross as the menu's
++ now. The chosen tab takes the theme's accent, as the menu's does (it was a dark box inside a box). On a phone the
++ head is two rows, as the menu's is: Menu, More and Close where the bar's bell and button are, and under them the
++ four tabs, full width, in the look of the menu's row that leads here.
++
## 4.188.0 — 2026-11-10
**The account's things are in the menu.** The owner, 2026-11-10, with the dropdown under the picture marked "Replace →
ddiff --git a/README.md b/README.md
iindex b84000d..aaa183c 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.188.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.189.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -831,7 +831,20 @@ One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menu
on its first tab, as the menu button does (there is no separate site
switcher). The site's name stays the link home; switcher: false keeps the
mark as a home link.
--The account's things (4.188): the person's picture opens the OpenVibe menu
++The balances (4.189): one pill, `#openvibe-chips-all`, at every width, with
++ one `.ovnav-chip-mini` per balance the person has (OpenCoins, Vibes, the money
++ balance; only those above zero, the largest first) and one of them showing.
++ `purse.js`, loaded beside the navbar, draws each currency's icon
++ (`OpenVibePurse.icon(id, size)` gives the same drawing to any surface), turns
++ the pill through the balances on the first page of a visit and now and then,
++ and on `setChip(id, value)` turns to that balance, counts the number and
++ plays the gain or spend effect (`OpenVibePurse.play(id, delta)`). A site's
++ single counter that is not a balance stays an ordinary chip.
++ The menu and the Dock (4.189): the menu button always opens this site's tab
++ (no tab is remembered; the picture still asks for You); under the tabs a row
++ opens the Dock on Vibe, Agents, Messages or Activity (`dock: false` leaves
++ it out), and the Dock's head has a button back to the menu.
++ The account's things (4.188): the person's picture opens the OpenVibe menu
on You, where the account's block is (who you are, add account, settings,
sign out, your other accounts, this site's own items from addMenuItem,
Display). There is no dropdown under the picture; the block rests in the bar,
ddiff --git a/STATUS.json b/STATUS.json
iindex c94fe55..b8893a2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.188.0",
++ "version": "4.189.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/dock.js b/dock.js
iindex 533ee74..2673d75 100644
---- a/dock.js
++++ b/dock.js
@@@ -163,6 +163,8 @@
@keyframes ovdk-grow { from { opacity: 0; transform: scale(.82); } }
@keyframes ovdk-rise { from { transform: translateY(100%); } }
.ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
++ /* The menu button's drawing (navbar.js) has its own fills: the line style above is not its. */
++ .ovdk svg.ovmb { fill: currentColor; stroke: none; stroke-width: 0; }
.ovdk-head { position: relative; flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 8px 8px 10px; cursor: grab; touch-action: none; user-select: none; }
.ovdk-tabs { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #ececf1) 6%, transparent); min-width: 0; flex: 0 1 auto; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
.ovdk-tabs::-webkit-scrollbar { display: none; }
@@@ -171,7 +173,7 @@
color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s, color .15s; }
.ovdk-tabs button svg { width: 17px; height: 17px; flex: none; }
.ovdk-tabs button:hover { color: var(--text-primary, #ececf1); }
-- .ovdk-tabs button[aria-selected="true"] { background: var(--bg-primary, #12141b); color: var(--text-primary, #ececf1); box-shadow: 0 1px 3px rgba(0,0,0,.25); }
++ .ovdk-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
.ovdk-tabs button[aria-selected="true"] svg { color: var(--accent-light, var(--accent, #60a5fa)); }
.ovdk-tabs button:focus-visible, .ovdk-acts button:focus-visible, .ovdk-foot :focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 1px; }
.ovdk-tabs .n { min-width: 18px; padding: 1px 6px; box-sizing: border-box; border-radius: 999px; font-size: 11px; font-weight: 800; line-height: 1.35; text-align: center; font-variant-numeric: tabular-nums;
@@@ -182,6 +184,7 @@
.ovdk-acts button { width: 40px; height: 40px; border: 0; border-radius: 12px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
.ovdk-acts button:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
.ovdk-acts button svg { width: 19px; height: 19px; display: block; }
++ .ovdk-acts .ovdk-tomenu span { display: none; }
.ovdk-orbx svg { width: 22px !important; height: 22px !important; overflow: visible; }
.ovdk-orbx { --ovo-a: var(--accent-light, var(--accent, #60a5fa)); }
.ovdk.is-in .ovdk-orbx .ovmb :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }
@@@ -317,6 +320,17 @@
.ovdk-tabs button svg { width: 19px; height: 19px; }
.ovdk-tabs .n { position: absolute; top: 4px; left: calc(50% + 6px); min-width: 17px; padding: 1px 5px; font-size: 10.5px; }
.ovdk-foot a, .ovdk-foot button { min-height: 44px; }
++ /* Two rows, as the menu's head (4.189.0): the way back to the menu, More and Close; under them the four tabs,
++ full width, in the look of the menu's row that leads here. */
++ .ovdk-head, .ovdk.from-orb .ovdk-head { flex-wrap: wrap; gap: 6px 4px; padding: 6px 12px 8px; }
++ .ovdk-acts { order: 0; flex: 1 0 100%; align-items: center; gap: 2px; }
++ .ovdk-acts .ovdk-tomenu { width: auto; margin-right: auto; padding: 0 14px 0 10px; gap: 8px; display: inline-flex; align-items: center; border-radius: 999px; font: inherit; font-size: 13.5px; font-weight: 700;
++ color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
++ .ovdk-acts .ovdk-tomenu span { display: inline; }
++ .ovdk-tabs { order: 1; flex: 1 0 100%; gap: 4px; padding: 0; border-radius: 0; background: transparent; overflow: visible; }
++ .ovdk-tabs button, .ovdk-tabs button:not([aria-selected="true"]) { height: 50px; gap: 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); }
++ .ovdk-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }
++ .ovdk-tabs button:not([aria-selected="true"]) svg { color: var(--accent-light, var(--accent, #60a5fa)); }
}
@media (min-width: 641px) and (max-width: 1023px) {
.ovdk, .ovdk[data-corner] { z-index: 10010; top: 0; bottom: 0; right: 0; left: auto; width: min(460px, calc(100vw - 48px)) !important; height: auto !important;
@@@ -348,6 +362,7 @@
<header class="ovdk-head">
<div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div>
<div class="ovdk-acts">
++ <button type="button" data-act="menu" class="ovdk-tomenu" title="The OpenVibe menu" aria-label="The OpenVibe menu: every site, the feed, you">${root.OpenVibeOrb ? root.OpenVibeOrb.svg(20) : ''}<span>Menu</span></button>
<button type="button" data-act="more" aria-haspopup="true" aria-expanded="false" title="More" aria-label="More: a timer, the Dock's button">${svg(G.more)}</button>
<button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX()}</button>
</div>
@@@ -369,6 +384,8 @@
if (j >= 0) { e.preventDefault(); setTab(tabs[j].dataset.tab, true); tabs[j].focus(); }
});
el.querySelector('[data-act="close"]').addEventListener('click', () => close());
++ // The way back to the menu: this sheet folds, the menu opens out of the same button.
++ { const m = el.querySelector('[data-act="menu"]'); if (m) { if (!root.OpenVibeNavbar || !root.OpenVibeOrb) m.hidden = true; m.addEventListener('click', () => { close(); setTimeout(() => { try { root.OpenVibeNavbar.openSwitcher(); } catch { /* no bar */ } }, 300); }); } }
el.querySelector('[data-act="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
el.querySelector('[data-act="timer"]').addEventListener('click', () => { const a = el.querySelector('[data-act="more"]'); closeMore(); withTimer((t) => t.menu(a)); });
ddiff --git a/files.js b/files.js
iindex bb08e79..948d668 100644
---- a/files.js
++++ b/files.js
@@@ -25,7 +25,7 @@ const BROWSER = Object.freeze([
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab
'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents
-- 'presence.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
++ 'presence.js', 'purse.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
ddiff --git a/navbar.js b/navbar.js
iindex 8e145b5..134eb78 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -175,13 +175,14 @@
switcher next to the logo); a tap lists each in full. */
/* The balances (owner, 2026-10-28: "the coins and vibes badges look a little weird… polish… use the space"): one
calm pill on the bar's own surface, each balance a coloured icon and a plain number, a hairline between. */
-- .openvibe-navbar .ovnav-chip.ovnav-chip--all { display: none; gap: 0; padding: 0 1px; font-size: 12.5px; --chip-c: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); border-color: color-mix(in srgb, var(--text-primary, #e6edf7) 13%, transparent); }
++ /* The balances: one pill, one showing (purse.js turns between them). */
++ .openvibe-navbar .ovnav-chip.ovnav-chip--all { display: inline-grid; gap: 0; padding: 0 1px; font-size: 13px; --chip-c: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); border-color: color-mix(in srgb, var(--text-primary, #e6edf7) 13%, transparent); }
.openvibe-navbar .ovnav-chip--all:hover, .openvibe-navbar .ovnav-chip--all[aria-expanded="true"] { background: color-mix(in srgb, var(--text-primary, #e6edf7) 11%, transparent); }
-- .ovnav-chip-mini { display: inline-flex; align-items: center; gap: 4px; padding: 0 6px; color: var(--text-primary, #e6edf7); }
-- .ovnav-chip-mini + .ovnav-chip-mini { border-left: 1px solid color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); }
-- .ovnav-chip-mini .ovnav-ic { color: var(--chip-c, inherit); }
++ .ovnav-chip-mini { grid-area: 1 / 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 0 7px; color: var(--text-primary, #e6edf7); opacity: 0; }
++ .ovnav-chip-mini.is-on { opacity: 1; }
++ .ovnav-chip-mini .ovnav-ic { color: var(--chip-c, inherit); width: 15px; height: 15px; }
.ovnav-chip-mini[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip-mini[data-tone="green"] { --chip-c: #4ade80; }
-- .ovnav-chip-mini .ovnav-ic { width: 14px; height: 14px; }
++ .openvibe-navbar-right.has-chips-all .ovnav-chip[data-chip-id] { display: none; }
.ovnav-chips-pop { position: absolute; top: calc(100% + 6px); right: 8px; z-index: 1002; min-width: 220px; padding: 6px; border-radius: 14px; display: none; flex-direction: column; gap: 2px;
background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 40px rgba(0,0,0,.45); }
.ovnav-chips-pop.open { display: flex; animation: openvibe-slide-down .18s ease; }
@@@ -190,10 +191,6 @@
.ovnav-chips-pop button .ovnav-ic { color: var(--chip-c, inherit); width: 16px; height: 16px; }
.ovnav-chips-pop button[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chips-pop button[data-tone="green"] { --chip-c: #4ade80; }
.ovnav-chips-pop b { margin-left: auto; font-variant-numeric: tabular-nums; }
-- @media (max-width: 560px) {
-- .openvibe-navbar-right.has-chips-all .ovnav-chip[data-chip-id] { display: none; }
-- .openvibe-navbar-right.has-chips-all .ovnav-chip--all { display: inline-flex; }
-- }
@media (max-width: 860px) { .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-featured-phone { display: inline-flex; } .openvibe-navbar:has(.ovnav-burger) .ovnav-orb:not(.ovnav-burger) { display: none; } .openvibe-navbar .openvibe-navbar-links, .openvibe-navbar .openvibe-navbar-spacer { display: none; } .openvibe-navbar .openvibe-navbar-right { margin-left: auto; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
/* A narrow phone never draws the site's name over the bar's buttons (owner, 2026-10-28: on openvibe.games, signed
@@@ -204,8 +201,7 @@
.openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }
/* Both balances on every phone (the owner, 2026-10-28: "you can only see one… use the full space"): the pill
tightens instead of folding to one. */
-- @media (max-width: 440px) { .ovnav-chip-mini { padding: 0 5px; gap: 3px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 12px; } .ovnav-chip-mini .ovnav-ic { width: 13px; height: 13px; } }
-- @media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 1px; } .ovnav-chip-mini { padding: 0 3px; gap: 2px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 11.5px; letter-spacing: -.2px; } .openvibe-navbar .openvibe-navbar-brand { gap: 4px; }
++ @media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 1px; } .openvibe-navbar .openvibe-navbar-brand { gap: 4px; }
.openvibe-navbar { padding-left: 8px; padding-right: 6px; gap: 2px; } .openvibe-navbar .ovnav-burger { width: 34px; } .openvibe-navbar .ovnav-featured-phone { width: 34px; height: 34px; } .openvibe-navbar .openvibe-navbar-avatar { width: 32px; height: 32px; box-sizing: border-box; }
.openvibe-navbar .openvibe-navbar-brand .name { font-size: 16.5px; letter-spacing: -.25px; } .openvibe-navbar .openvibe-navbar-brand .flame { min-width: 28px; height: 28px; padding: 0 2px; } .openvibe-navbar .openvibe-navbar-brand .flame .ov-mark { width: 34px !important; } }
/* Under 350px the mark alone names the site, so both balances keep their icons (a number alone says nothing). */
@@@ -214,12 +210,6 @@
website you're on seems to get cut off", openvibe.live showing "Li…" beside 161k | 400, the messages, the bell
and the picture). Up to 480px the two balances stack in one pill, each with its icon (both still show, as asked
on 10-28): the pill is about 53px instead of 90-103px, and the name has that room. */
-- @media (max-width: 480px) {
-- .openvibe-navbar .ovnav-chip.ovnav-chip--all { flex-direction: column; align-items: stretch; justify-content: center; gap: 1px; height: 34px; padding: 2px; border-radius: 10px; font-size: 11.5px; line-height: 1; letter-spacing: -.1px; }
-- .openvibe-navbar .ovnav-chip--all .ovnav-chip-mini { height: 14px; padding: 0 4px; gap: 3px; justify-content: flex-start; font-variant-numeric: tabular-nums; }
-- .openvibe-navbar .ovnav-chip--all .ovnav-chip-mini + .ovnav-chip-mini { border-left: 0; }
-- .openvibe-navbar .ovnav-chip--all .ovnav-chip-mini .ovnav-ic { width: 11px; height: 11px; flex: none; }
-- }
.openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
@@@ -558,7 +548,7 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '9c0b9621ca9f', 'dock.js': '00e20b4b2895', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': '1e6598f78ba4', 'appbar.js': '4e34b7d60372', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '135d730d03fd', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
++ const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '04aa1cedb6b4', 'dock.js': '4235b5850241', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': '1e6598f78ba4', 'appbar.js': '4e34b7d60372', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '135d730d03fd', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e', 'purse.js': 'd1d5b341c610' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@@ -1057,7 +1047,6 @@
return String(Math.round(n));
}
const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0];
-- /** The chips folded into one (shown instead of them on a phone): each chip's icon and short value. */
// New messages are counted on the bell (4.187.0; the owner, 2026-11-10: "instead of having messaging and
// notifications, just have the notifications … it'll show you new messages as well"): there is no Messages button.
let _accountEl = null; // the account's block, built at render, hosted by the menu's You tab
@@@ -1074,13 +1063,15 @@
function chipsAllHTML(chips) {
const list = (Array.isArray(chips) ? chips : []).filter((c) => c && !c.hidden);
-- if (list.length < 2) return '';
-- return `<button type="button" class="ovnav-chip ovnav-chip--all" id="openvibe-chips-all" aria-haspopup="true" aria-expanded="false" aria-label="Your balances: ${escapeAttr(list.map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', '))}">${list.map((c) => `<span class="ovnav-chip-mini" data-chip-mini="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-mv">${escapeAttr(shortValue(c.value))}</span></span>`).join('')}</button>`;
++ if (list.length < 2 && !(list[0] && ['coins', 'vibes', 'balance'].includes(list[0].id))) return '';
++ return `<button type="button" class="ovnav-chip ovnav-chip--all" id="openvibe-chips-all" aria-haspopup="true" aria-expanded="false" aria-label="Your balances: ${escapeAttr(list.map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', '))}">${list.map((c, i) => `<span class="ovnav-chip-mini${i ? '' : ' is-on'}" data-chip-mini="${escapeAttr(c.id)}" data-v="${escapeAttr(c.value == null ? '' : c.value)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-mv">${escapeAttr(shortValue(c.value))}</span></span>`).join('')}</button>`;
}
function bindChipsAll(nav) {
const btn = nav.querySelector('#openvibe-chips-all'); if (!btn || typeof btn.addEventListener !== 'function') return;
const right = typeof btn.closest === 'function' ? btn.closest('.openvibe-navbar-right') : null;
if (right && right.classList) right.classList.add('has-chips-all');
++ if (root.OpenVibePurse) root.OpenVibePurse.mount(btn);
++ else if (!document.getElementById('ov-purse-loader')) { const sc = document.createElement('script'); sc.id = 'ov-purse-loader'; sc.src = siblingV('purse.js'); sc.async = true; document.head.appendChild(sc); }
let pop = null;
const close = () => { if (pop) pop.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
btn.addEventListener('click', (e) => {
@@@ -2006,11 +1997,11 @@
}
function walletOrder(data) {
const order = ['coins', 'vibes', 'balance'];
-- if (!data) return order.slice(0, 2);
++ if (!data) return order.slice(0, 1);
const value = (id) => Number(data.balances[id].amount) / Math.pow(10, data.balances[id].scale) / (id === 'vibes' ? data.bits_per_usd || 100 : 1);
-- order.sort((a, b) => value(b) - value(a));
-- if (data.used_ai_this_month && BigInt(data.balances.balance.amount) === 0n) return ['balance', ...order.filter((id) => id !== 'balance')].slice(0, 2);
-- return order.slice(0, 2);
++ const have = order.filter((id) => value(id) > 0).sort((a, b) => value(b) - value(a));
++ if (data.used_ai_this_month && BigInt(data.balances.balance.amount) === 0n) have.unshift('balance');
++ return have.length ? have : order.slice(0, 1);
}
function syncWalletChips() {
if (!_config.wallet) return;
@@@ -2309,6 +2300,7 @@
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
look: bindLook,
++ dock: _config.dock !== false && _config.user && !_config.user.is_anon && _config.token ? (t) => { if (root.OpenVibeSearch) root.OpenVibeSearch.close(); openDock(t); } : null,
// The account's own block (who you are, your accounts, sign out, this site's items): the You tab hosts it.
account: (host) => { if (!_accountEl || !host) return false; host.appendChild(_accountEl); loadWallet(_accountEl); loadOvIcons(_accountEl); return true; },
onClose: () => { if (_accountEl && _navEl && _accountEl.parentNode !== _navEl) _navEl.appendChild(_accountEl); const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });
@@@ -2612,6 +2604,7 @@
lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));
document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; });
document.querySelectorAll(`[data-chip-mini="${id}"] .ovnav-chip-mv`).forEach(el => { el.textContent = shortValue(value); });
++ if (root.OpenVibePurse) root.OpenVibePurse.set(id, value);
document.querySelectorAll(`[data-chip-row="${id}"] b`).forEach(el => { el.textContent = value == null ? '' : String(value); });
const all = document.getElementById('openvibe-chips-all');
if (all) all.setAttribute('aria-label', `Your balances: ${(_config.chips || []).filter((c) => c && !c.hidden).map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', ')}`);
ddiff --git a/package-lock.json b/package-lock.json
iindex 43dedd7..58e1fd1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.188.0",
++ "version": "4.189.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.188.0",
++ "version": "4.189.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 9e59fef..7789d02 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.188.0",
++ "version": "4.189.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -49,6 +49,7 @@
"./action-center": "./action-center.js",
"./dock": "./dock.js",
"./presence": "./presence.js",
++ "./purse": "./purse.js",
"./nav-slots": "./nav-slots.js",
"./overlay": "./overlay.js",
"./dock-vibe": "./dock-vibe.js",
ddiff --git a/purse.js b/purse.js
nnew file mode 100644
iindex 0000000..f4a7f50
---- /dev/null
++++ b/purse.js
@@@ -0,0 +1,186 @@
++/* purse.js — the balances in the bar as one pill (4.189.0). The owner, 2026-11-10: "we just show one, but it can
++ animate between all three, especially when you spend some … first load it should scroll through them all, or every once
++ in a while … a custom animation for gaining and losing, with coins coming out of it … nice icons that are animated,
++ designed for the three currencies … it only needs to show the things you have."
++
++ navbar.js draws the pill (#openvibe-chips-all: one .ovnav-chip-mini per balance the person has, the first one showing)
++ and loads this file beside itself. This file gives each currency its own drawn icon, rolls the pill through the
++ balances on the first page of a visit and now and then after, and, when a balance changes, turns to it, counts the
++ number up or down and sends a few of its pieces in or out. It reads nothing from the network.
++
++ OpenVibePurse.mount(button) the pill, after each draw of the bar
++ OpenVibePurse.set(id, value) a balance changed ('coins', '1,234')
++ OpenVibePurse.icon(id, size) the currency's icon as SVG text ('coins' | 'vibes' | 'balance'), for other surfaces
++ OpenVibePurse.play(id, delta) the gain or spend effect alone (openvibe.design shows it) */
++(function (root) {
++ 'use strict';
++ if (!root || !root.document || root.OpenVibePurse) return;
++ const doc = root.document;
++ const svg = (size, body) => `<svg class="ovp-ic" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${body}</svg>`;
++ // Each icon: the currency's colour as its body (--pc), the detail cut in a darker mix of it, one light edge.
++ const ICONS = {
++ // OpenCoins: a coin with the network's ring struck in it.
++ coins: '<g class="ovp-spin"><circle cx="12" cy="12" r="9.6" fill="var(--pc)"/><circle cx="12" cy="12" r="9.6" fill="none" stroke="var(--pd)" stroke-width="1.3" opacity=".55"/><circle cx="12" cy="12" r="4.4" fill="none" stroke="var(--pd)" stroke-width="2.5"/><path d="M5.6 9.2a7 7 0 0 1 4.6-4.1" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" opacity=".7"/></g>',
++ // Vibes: a cut gem (bought, then given).
++ vibes: '<g class="ovp-gem"><path d="M7.2 3.6h9.6l4.4 5.6L12 21 2.8 9.2z" fill="var(--pc)" stroke="var(--pc)" stroke-width="1.6" stroke-linejoin="round"/><path d="M2.8 9.2h18.4M8.9 9.2 12 20.4l3.1-11.2M7.2 3.6l1.7 5.6L12 3.6l3.1 5.6 1.7-5.6" fill="none" stroke="var(--pd)" stroke-width="1.25" stroke-linejoin="round" opacity=".8"/><path class="ovp-glint" d="M6.3 7.3 8 5.2" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" opacity=".75"/></g>',
++ // The money balance: a note with a round seal.
++ balance: '<g class="ovp-note"><rect x="2.2" y="5.6" width="19.6" height="12.8" rx="3.2" fill="var(--pc)"/><circle cx="12" cy="12" r="3.1" fill="none" stroke="var(--pd)" stroke-width="2.1"/><path d="M5.6 9.2v.01M18.4 14.8v.01" stroke="var(--pd)" stroke-width="2.2" stroke-linecap="round"/><path d="M5 8.4h3.4" stroke="#fff" stroke-width="1.3" stroke-linecap="round" opacity=".6"/></g>',
++ };
++ const CSS = `
++.ovnav-chip-mini, .ovp-bit, .ovp-delta, .ovp-icw { --pc: var(--text-secondary, #a8b3c4); --pd: color-mix(in oklab, var(--pc) 42%, #000); }
++/* Each colour leans toward the page's text, so it is brighter on a dark theme and deeper on a light one. */
++[data-purse="coins"] { --pc: color-mix(in oklab, #f6b21d 88%, var(--text-primary, #e6edf7)); }
++[data-purse="vibes"] { --pc: color-mix(in oklab, #1fc78c 86%, var(--text-primary, #e6edf7)); }
++[data-purse="balance"] { --pc: color-mix(in oklab, var(--accent, #3b82f6) 78%, var(--text-primary, #e6edf7)); }
++.ovp-ic { display: block; flex: none; overflow: visible; }
++.openvibe-navbar .ovnav-chip.ovnav-chip--all.is-purse { height: 32px; padding: 0 3px 0 2px; border-radius: 999px; overflow: hidden; font-size: 13.5px; font-weight: 750; letter-spacing: -.1px; transition: background-color .2s, border-color .3s, box-shadow .3s, transform .15s; }
++.openvibe-navbar .ovnav-chip--all.is-purse:active { transform: scale(.95); }
++.is-purse .ovnav-chip-mini { gap: 6px; padding: 0 8px 0 5px; font-variant-numeric: tabular-nums; transform: translateY(calc(85% * var(--ov-move, 1))); transition: transform .5s cubic-bezier(.3, 1.35, .45, 1), opacity .28s; }
++.is-purse .ovnav-chip-mini.is-on { transform: none; }
++.is-purse .ovnav-chip-mini.is-out { transform: translateY(calc(-85% * var(--ov-move, 1))); }
++.is-purse .ovnav-chip-mini .ovp-ic { width: 19px; height: 19px; filter: drop-shadow(0 1px 1.5px color-mix(in srgb, var(--pd) 55%, transparent)); }
++/* Each icon moves its own way as its balance turns up. */
++.is-purse .is-on .ovp-spin { transform-origin: 12px 12px; animation: ovpSpin .7s cubic-bezier(.3, .9, .3, 1) both; }
++.is-purse .is-on .ovp-gem { transform-origin: 12px 12px; animation: ovpGem .7s cubic-bezier(.3, 1.4, .4, 1) both; }
++.is-purse .is-on .ovp-note { transform-origin: 12px 12px; animation: ovpNote .6s cubic-bezier(.3, 1.3, .4, 1) both; }
++.is-purse .is-on .ovp-glint { animation: ovpGlint 2.8s ease-in-out .6s infinite; }
++@keyframes ovpSpin { from { transform: scaleX(calc(1 - 2 * var(--ov-move, 1))); } 50% { transform: scaleX(calc(1 - .9 * var(--ov-move, 1))); } }
++@keyframes ovpGem { from { transform: rotate(calc(-28deg * var(--ov-move, 1))) scale(.6); } }
++@keyframes ovpNote { from { transform: translateX(calc(-5px * var(--ov-move, 1))) rotate(calc(-12deg * var(--ov-move, 1))); } }
++@keyframes ovpGlint { 0%, 70%, 100% { opacity: .75; } 82% { opacity: .1; } }
++/* A balance went up or down: the pill takes that balance's colour for a moment and its icon jumps or dips. */
++.openvibe-navbar .ovnav-chip--all.is-purse.is-gain, .openvibe-navbar .ovnav-chip--all.is-purse.is-loss { border-color: color-mix(in srgb, var(--pf, currentColor) 60%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pf, currentColor) 16%, transparent); }
++.is-purse.is-gain .is-on .ovp-ic { animation: ovpHop .55s cubic-bezier(.3, 1.6, .4, 1); }
++.is-purse.is-loss .is-on .ovp-ic { animation: ovpDip .5s ease-out; }
++@keyframes ovpHop { 35% { transform: translateY(calc(-4px * var(--ov-move, 1))) scale(1.22); } }
++@keyframes ovpDip { 35% { transform: translateY(calc(2px * var(--ov-move, 1))) scale(.84); } }
++.ovp-bit, .ovp-delta { position: fixed; z-index: 2147483000; pointer-events: none; left: 0; top: 0; will-change: transform, opacity; }
++.ovp-delta { padding: 2px 8px; border-radius: 999px; font: 800 12.5px/1.3 var(--font, system-ui, sans-serif); font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--pc);
++ background: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, #111826)) 92%, var(--pc)); border: 1px solid color-mix(in srgb, var(--pc) 45%, transparent); box-shadow: 0 6px 18px rgba(0, 0, 0, .28); }
++@media (max-width: 400px) { .openvibe-navbar .ovnav-chip.ovnav-chip--all.is-purse { font-size: 13px; letter-spacing: -.2px; } .is-purse .ovnav-chip-mini { gap: 4px; padding: 0 6px 0 4px; } .is-purse .ovnav-chip-mini .ovp-ic { width: 17px; height: 17px; } }
++:root:root[data-ov-motion=off] .is-purse .ovnav-chip-mini, :root:root[data-ov-motion=off] .is-purse .ovnav-chip-mini * { animation: none !important; transition: none !important; }`;
++
++ const num = (v) => { const s = v == null ? '' : String(v).trim(); if (!/^-?[\d,]+(\.\d+)?$/.test(s)) return null; const n = Number(s.replace(/,/g, '')); return isFinite(n) ? n : null; };
++ /** 160774 → "161k" (the same short form the bar writes). */
++ function short(n) {
++ const a = Math.abs(n);
++ if (a >= 1e6) return `${(n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, '')}M`;
++ if (a >= 1e4) return `${Math.round(n / 1e3)}k`;
++ if (a >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, '')}k`;
++ return String(Math.round(n));
++ }
++ const motion = () => { const m = doc.documentElement.getAttribute('data-ov-motion'); return m === 'off' ? 0 : m === 'reduced' ? .35 : 1; };
++ const seen = () => { try { return root.sessionStorage.getItem('ovp-toured') === '1'; } catch { return true; } };
++ const markSeen = () => { try { root.sessionStorage.setItem('ovp-toured', '1'); } catch { /* a private window: it tours again */ } };
++
++ let btn = null, minis = [], home = 0, at = 0, timers = [], last = {}, busy = false;
++ const later = (fn, ms) => { const t = setTimeout(fn, ms); timers.push(t); return t; };
++ const clear = () => { timers.forEach(clearTimeout); timers = []; };
++ const visible = () => btn && btn.isConnected && !doc.hidden && btn.getClientRects().length > 0;
++
++ function style() {
++ if (doc.getElementById('ovp-style')) return;
++ const st = doc.createElement('style'); st.id = 'ovp-style'; st.textContent = CSS; doc.head.appendChild(st);
++ }
++ /** Turn the pill to one balance: the one showing leaves upward, the next comes up from below. */
++ function show(i) {
++ if (!minis.length) return; i = ((i % minis.length) + minis.length) % minis.length;
++ if (i === at && minis[i].classList.contains('is-on')) return;
++ const out = minis[at];
++ minis.forEach((m) => m.classList.remove('is-out'));
++ if (out && out !== minis[i]) { out.classList.remove('is-on'); out.classList.add('is-out'); later(() => out.classList.remove('is-out'), 520); }
++ minis[i].classList.add('is-on'); at = i;
++ }
++ /** Now and then the pill turns to the next balance for a few seconds, then back to the largest. */
++ function idle() {
++ if (minis.length < 2) return;
++ let next = home;
++ const tick = () => {
++ if (!btn || !btn.isConnected) return;
++ if (!busy && visible()) { next = (next + 1) % minis.length; if (next === home) next = (next + 1) % minis.length; show(next); later(() => { if (!busy) show(home); }, 3600); }
++ later(tick, 42000);
++ };
++ later(tick, 42000);
++ }
++ /** The first page of a visit: each balance in turn, ending on the largest. */
++ function tour() {
++ if (minis.length < 2 || seen()) return idle();
++ markSeen();
++ let i = 0;
++ const step = () => { if (busy) return idle(); i += 1; if (i < minis.length) { show(i); later(step, 2300); } else { show(home); idle(); } };
++ later(step, 2600);
++ }
++ function mount(el) {
++ if (!el || !el.querySelectorAll) return;
++ style(); clear(); busy = false;
++ btn = el; minis = Array.from(el.querySelectorAll('.ovnav-chip-mini')); home = 0; at = 0; last = {};
++ el.classList.add('is-purse');
++ minis.forEach((m, i) => {
++ const id = m.getAttribute('data-chip-mini');
++ last[id] = num(m.getAttribute('data-v'));
++ if (ICONS[id]) {
++ m.setAttribute('data-purse', id);
++ const old = m.querySelector('.ovnav-ic, .ovp-ic'); const holder = doc.createElement('span'); holder.innerHTML = svg(19, ICONS[id]);
++ if (old) old.replaceWith(holder.firstChild); else m.insertBefore(holder.firstChild, m.firstChild);
++ }
++ m.classList.toggle('is-on', i === 0); m.classList.remove('is-out');
++ });
++ tour();
++ }
++ /** A few of the currency's pieces: into the pill from below on a gain, out of it and down on a spend. */
++ function pieces(id, rect, gain, k) {
++ if (!ICONS[id] || typeof doc.body.animate !== 'function' || k === 0) return;
++ const cx = rect.left + 14, cy = rect.top + rect.height / 2, n = k < 1 ? 3 : 7;
++ for (let i = 0; i < n; i += 1) {
++ const b = doc.createElement('span'); b.className = 'ovp-bit'; b.setAttribute('data-purse', id); b.innerHTML = svg(13, ICONS[id]);
++ doc.body.appendChild(b);
++ const spread = ((i / (n - 1)) - .5) * 2, dx = spread * 34 * k + (Math.random() - .5) * 10, dy = (38 + Math.random() * 26) * k, turn = (Math.random() - .5) * 260 * k;
++ const far = `translate(${cx - 6 + dx}px, ${cy - 6 + dy}px) rotate(${turn}deg) scale(.9)`, near = `translate(${cx - 6}px, ${cy - 6}px) rotate(0deg) scale(.55)`;
++ const mid = `translate(${cx - 6 + dx * .7}px, ${cy - 6 + dy * (gain ? .75 : .25) - (gain ? 0 : 9 * k)}px) rotate(${turn / 2}deg) scale(1)`;
++ const frames = gain ? [{ transform: far, opacity: 0 }, { transform: mid, opacity: 1, offset: .3 }, { transform: near, opacity: .9, offset: .92 }, { transform: near, opacity: 0 }]
++ : [{ transform: near, opacity: 0 }, { transform: mid, opacity: 1, offset: .28 }, { transform: far, opacity: 0 }];
++ const a = b.animate(frames, { duration: gain ? 760 : 900, delay: i * (gain ? 55 : 40), easing: gain ? 'cubic-bezier(.5,0,.3,1)' : 'cubic-bezier(.2,.6,.5,1)', fill: 'both' });
++ const done = () => b.remove(); a.onfinish = done; a.oncancel = done; setTimeout(done, 2200);
++ }
++ }
++ /** "+15" or "−200" under the pill: it rises into the pill on a gain and falls away on a spend. */
++ function delta(id, rect, d, k) {
++ if (typeof doc.body.animate !== 'function') return;
++ const t = doc.createElement('span'); t.className = 'ovp-delta'; t.setAttribute('data-purse', id);
++ t.textContent = `${d > 0 ? '+' : '−'}${Math.abs(d) >= 1e4 ? short(Math.abs(d)) : Math.abs(d).toLocaleString()}`;
++ doc.body.appendChild(t);
++ const x = Math.max(6, Math.min(root.innerWidth - t.offsetWidth - 6, rect.left + rect.width / 2 - t.offsetWidth / 2)), y = rect.bottom + 6, m = 16 * (k || .01);
++ const frames = d > 0 ? [{ transform: `translate(${x}px, ${y + m}px)`, opacity: 0 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .22 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .78 }, { transform: `translate(${x}px, ${y - m * .6}px) scale(.9)`, opacity: 0 }]
++ : [{ transform: `translate(${x}px, ${y - m * .5}px)`, opacity: 0 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .22 }, { transform: `translate(${x}px, ${y}px)`, opacity: 1, offset: .75 }, { transform: `translate(${x}px, ${y + m}px)`, opacity: 0 }];
++ const a = t.animate(frames, { duration: 1900, easing: 'ease-out', fill: 'both' });
++ const done = () => t.remove(); a.onfinish = done; a.oncancel = done; setTimeout(done, 2600);
++ }
++ function play(id, d) {
++ if (!btn || !visible() || !d) return;
++ const i = minis.findIndex((m) => m.getAttribute('data-chip-mini') === id); if (i < 0) return;
++ const k = motion(), rect = btn.getBoundingClientRect();
++ busy = true; clear(); show(i);
++ btn.style.setProperty('--pf', root.getComputedStyle(minis[i]).getPropertyValue('--pc') || 'currentColor');
++ btn.classList.remove('is-gain', 'is-loss'); void btn.offsetWidth; btn.classList.add(d > 0 ? 'is-gain' : 'is-loss');
++ pieces(id, rect, d > 0, k); delta(id, rect, d, k);
++ later(() => { btn.classList.remove('is-gain', 'is-loss'); }, 1500);
++ later(() => { busy = false; show(home); idle(); }, 3400);
++ }
++ /** Count the number from what it was to what it is, in the bar's short form. */
++ function count(el, from, to) {
++ if (!el || motion() === 0 || typeof root.requestAnimationFrame !== 'function') return;
++ const t0 = root.performance.now(), ms = 800;
++ const step = (t) => { const p = Math.min(1, (t - t0) / ms), e = 1 - Math.pow(1 - p, 3); if (!el.isConnected) return; el.textContent = short(from + (to - from) * e); if (p < 1) root.requestAnimationFrame(step); };
++ root.requestAnimationFrame(step);
++ }
++ function set(id, value) {
++ const n = num(value), was = last[id];
++ last[id] = n;
++ if (!btn || n == null || was == null || n === was) return;
++ const m = minis.find((x) => x.getAttribute('data-chip-mini') === id); if (!m) return;
++ m.setAttribute('data-v', String(value));
++ play(id, n - was); count(m.querySelector('.ovnav-chip-mv'), was, n);
++ }
++ root.OpenVibePurse = { mount, set, play, icon: (id, size) => (ICONS[id] ? svg(size || 20, ICONS[id]) : ''), ids: Object.keys(ICONS) };
++ const now = doc.getElementById('openvibe-chips-all'); if (now) mount(now);
++})(typeof window !== 'undefined' ? window : null);
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 0243f66..8f66263 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -15,7 +15,9 @@ const ROOT = path.join(__dirname, '..');
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',
// 4.173.0: the wallet sheet (4.170.0) went out without ?v=, so a fix to it reached phones up to a day late.
-- 'ov-wallet.js', 'ov-wallet.css'];
++ 'ov-wallet.js', 'ov-wallet.css',
++ // 4.189.0: the balances pill's icons and effects.
++ 'purse.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
ddiff --git a/search.js b/search.js
iindex 1811096..87bc036 100644
---- a/search.js
++++ b/search.js
@@@ -66,11 +66,6 @@
// user last had open … the main tab should just show links that exist on that site, and under that the full list
// of sites"). The first is labelled with this site's own name.
const TABS = [['here', ''], ['feed', 'Feed'], ['you', 'You']];
-- const TAB_KEY = 'ovs-tab';
-- // The tab is remembered per site (11-09, the owner: on a new site the menu opened on You, "confusing"): another site
-- // opens on its own tab first; the same site opens where the person left it.
-- const savedTab = () => { try { const [v, host] = String(localStorage.getItem(TAB_KEY) || '').split('|'); if (host && host !== location.hostname) return null; return TABS.some(([id]) => id === v) ? v : null; } catch { return null; } };
-- const saveTab = (v) => { try { localStorage.setItem(TAB_KEY, `${v}|${location.hostname}`); } catch { /* private window */ } };
// When the Feed was last looked at, for its "new since" count (11-09, the owner: "if there's anything new in the feed
// since last time you've looked… a badge").
const FEED_SEEN = 'ovs-feed-seen';
@@@ -218,7 +213,17 @@
.ovs-x span { display: none; }
.ovs-x:active { background: var(--bg-active, rgba(255,255,255,.14)); color: var(--text-primary, #fff); }
}
--.ovs-tabs { display: flex; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
++.ovs-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
++/* The way over to the Dock: a quieter row under the tabs, four small buttons with the Dock's own names. */
++.ovs-bridge { flex: 1 0 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; margin-top: 4px; }
++.ovs-bridge-b { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 0; min-height: 44px; padding: 0 6px; border-radius: 12px; cursor: pointer; font-size: 12.5px; font-weight: 650;
++ color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); transition: background-color .15s, color .15s, transform .12s; }
++.ovs-bridge-b span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovs-bridge-i { display: grid; place-items: center; flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovs-bridge-b:hover, .ovs-bridge-b:focus-visible { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
++.ovs-bridge-b:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovs-bridge-b:active { transform: scale(.96); }
++@media (max-width: 640px) { .ovs-bridge-b { flex-direction: column; gap: 2px; min-height: 50px; padding: 4px 2px; font-size: 11.5px; } .ovs-bridge-i svg { width: 19px; height: 19px; } }
/* The menu's three tabs (4.149.0): one segmented row under the field; the chosen one in the accent. */
.ovs-starttabs { gap: 6px; padding: 2px 12px 10px; }
.ovs-starttabs .ovs-tab { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 38px; padding: 0 10px; border-radius: 12px; font-size: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }
@@@ -241,7 +246,15 @@
.ovs-grid.is-mini { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; padding: 0 2px 4px; }
.ovs-grid.is-mini .ovs-tile { gap: 8px; min-height: 40px; padding: 4px 8px; border-radius: 10px; }
.ovs-grid.is-mini .ovs-ic { width: 26px; height: 26px; border-radius: 8px; }
--.ovs-grid.is-mini .ovs-tile b { font-size: 13px; font-weight: 600; }
++.ovs-grid.is-mini .ovs-tile b { font-size: 13.5px; font-weight: 650; }
++/* 4.189.0: the sites in their groups are full touch targets with a larger mark, and each group's name runs into a
++ 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; }
++.ovs-grid.is-mini .ovs-tile:active { transform: scale(.97); }
++.ovs-grid.is-mini .ovs-ic { width: 32px; height: 32px; border-radius: 10px; }
++.ovs-group[data-sec="all"] { display: flex; align-items: center; gap: 10px; }
++.ovs-group[data-sec="all"]::after { content: ""; flex: 1; height: 1px; background: var(--border, rgba(255,255,255,.1)); }
.ovs-tabs[hidden] { display: none; }
.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }
.ovs-tab[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); }
@@@ -286,8 +299,14 @@
.ovs-app { display: flex; flex-direction: column; align-items: center; gap: 7px; min-width: 0; padding: 10px 2px 8px; border-radius: 16px; color: inherit; text-decoration: none; }
.ovs .ovs-appic { width: 54px; height: 54px; border-radius: 17px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); transition: transform .18s cubic-bezier(.2,1.4,.4,1), box-shadow .18s; }
.ovs-appname { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
--.ovs-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 6px; padding: 0 2px 2px; }
--.ovs-link { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 44px; padding: 0 10px; border-radius: 12px; color: inherit; text-decoration: none; font-size: 14px; font-weight: 600; }
++/* This site's pages: named as such, in the case they are written in, behind a dot that says "you are here"; each a tile. */
++.ovs-group[data-sec="here"] { gap: 9px; padding-top: 12px; font-size: 13.5px; font-weight: 750; letter-spacing: 0; text-transform: none; color: var(--text-secondary, #a8b3c4); }
++.ovs-group[data-sec="here"]::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-light, var(--accent, #60a5fa)); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); animation: ovs-here 2.4s ease-in-out infinite; }
++@keyframes ovs-here { 50% { box-shadow: 0 0 0 calc(4px + 3px * var(--ov-move, 1)) color-mix(in srgb, var(--accent, #3b82f6) 8%, transparent); } }
++.ovs-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 2px 4px 6px; }
++.ovs-link { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 46px; padding: 0 12px; border-radius: 12px; color: inherit; text-decoration: none; font-size: 14px; font-weight: 600; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4.5%, transparent); transition: background-color .15s, transform .12s; }
++.ovs-link:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }
++.ovs-link:active { transform: scale(.97); }
.ovs-link svg { width: 16px; height: 16px; flex: none; color: var(--text-muted, #93a0b4); }
.ovs-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovs-link.is-here { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 42%, transparent); color: var(--text-primary, #e6edf3); }
@@@ -431,7 +450,7 @@
el.appendChild(orbEl);
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
-- tab = b.getAttribute('data-tab'); saveTab(tab); if (tab === 'feed') markFeedSeen(); render(''); listEl.scrollTop = 0;
++ tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); render(''); listEl.scrollTop = 0;
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
if (touch) b.focus(); else input.focus();
});
@@@ -715,7 +734,7 @@
if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }
const ic = icon(it);
-- if (as === 'mini') { const sp = ic.querySelector('.ov-icon'); if (sp) sp.setAttribute('data-size', '22'); }
++ if (as === 'mini') { const sp = ic.querySelector('.ov-icon'); if (sp) sp.setAttribute('data-size', '26'); }
a.append(ic, txt);
box.appendChild(a);
});
@@@ -1010,8 +1029,29 @@
else if (id === 'feed' && fresh) { const n = document.createElement('b'); n.className = 'ovs-tabnew'; n.textContent = fresh > 9 ? '9+' : String(fresh); n.setAttribute('aria-label', `${fresh} new since you last looked`); b.appendChild(n); }
tabsEl.appendChild(b);
}
++ // The way over to the Dock (4.189.0): Vibe, your agents, messages and what is new are one press from here, and
++ // the Dock's head has the way back. Only where the bar can open the Dock for this person.
++ if (typeof ctx.dock === 'function') {
++ const row = document.createElement('div'); row.className = 'ovs-bridge'; row.setAttribute('role', 'group'); row.setAttribute('aria-label', 'Your Dock');
++ for (const [id, label] of [['vibe', 'Vibe'], ['agent', 'Agents'], ['chats', 'Messages'], ['activity', 'Activity']]) {
++ const b = document.createElement('button'); b.type = 'button'; b.className = 'ovs-bridge-b'; b.setAttribute('data-dock', id);
++ const ic = document.createElement('span'); ic.className = 'ovs-bridge-i'; ic.innerHTML = BRIDGE_SVG[id];
++ const t = document.createElement('span'); t.textContent = label;
++ b.append(ic, t);
++ b.addEventListener('click', () => { try { ctx.dock(id); } catch { /* the Dock is optional */ } });
++ row.appendChild(b);
++ }
++ tabsEl.appendChild(row);
++ }
if (typeof ctx.icons === 'function') { try { ctx.icons(tabsEl); } catch { /* the glyphs come with ov-icons.js */ } }
}
++ const bsvg = (d) => `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
++ const BRIDGE_SVG = {
++ vibe: bsvg('<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>'),
++ agent: bsvg('<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13v.01M14.5 13v.01M3 12.5v2M21 12.5v2"/>'),
++ chats: bsvg('<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5h-7L8 20v-3.5H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5z"/>'),
++ activity: bsvg('<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>'),
++ };
/** What every tab needs: the sites, this one, and the person's ranking of them. */
function basis() {
@@@ -1060,13 +1100,14 @@
if (len > bestLen) { best = i; bestLen = len; }
});
if (best >= 0) pages[best].here = true;
-- if (pages.length) groups.push({ title: `On ${here()}`, as: 'links', items: pages, section: 'here' });
++ if (pages.length) groups.push({ title: `Pages on ${here()}`, as: 'links', items: pages, section: 'here' });
const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];
if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));
const inGroup = (x, g) => !g.id || x.group === g.id || (!fgroups.some((y) => y.id === x.group) && g === fgroups[fgroups.length - 1]);
const mini = (x) => siteItem(x, all);
fgroups.forEach((g, i) => {
-- const list = all.filter((x) => inGroup(x, g)).map(mini);
++ // The site you are on is first in its group (its group is already first): the menu opens where you are.
++ const list = all.filter((x) => inGroup(x, g)).sort((a, b) => (mineSite ? (b.id === mineSite.id) - (a.id === mineSite.id) : 0)).map(mini);
if (list.length) groups.push({ title: i === 0 && fgroups.length > 1 ? g.name : g.name, as: 'mini', items: list, section: 'all' });
});
const soon = f && Array.isArray(f.soon) ? f.soon : [];
@@@ -1410,7 +1451,8 @@
// tab, whose second half is every site; "tools" is You); else this site.
// "sites" is a request for the list of every site (the legacy drawer's All sites): it opens this site's tab.
const asked = { here: 'here', sites: 'here', feed: 'feed', you: 'you', tools: 'you' }[opts.tab];
-- tab = opts.tab === 'sites' ? 'here' : (savedTab() || asked || 'here');
++ // The menu opens on this site's tab unless a tab was asked (the picture asks You): no tab is remembered (4.189.0).
++ tab = asked || 'here';
allOpen = false; hereOpen = true; leadHere = false; discoverPick = null;
drainCards();
syncFeeds();
ddiff --git a/test/menu-close-chrome.test.js b/test/menu-close-chrome.test.js
iindex 7d78d40..348e5ad 100644
---- a/test/menu-close-chrome.test.js
++++ b/test/menu-close-chrome.test.js
@@@ -75,8 +75,10 @@ async function openMenu(p) {
assert.ok(await p.evaluate('document.activeElement.classList.contains("ovs-orb")'), 'the phone menu keeps focus on its orb');
await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await p.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
-- await wait(450);
-- assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes the menu from its orb');
++ // Up to three seconds: the fold takes 280 ms, and a slow runner (macOS in CI) has needed more than 450.
++ let stillOpen = true;
++ for (let i = 0; i < 30 && stillOpen; i += 1) { await wait(100); stillOpen = await p.evaluate('!!document.querySelector(".ovs.is-open")'); }
++ assert.strictEqual(stillOpen, false, 'Escape closes the menu from its orb');
assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing unlocks the page');
await openMenu(p);
await p.evaluate('document.querySelector(".ovs.is-open .ovs-orb").click(); 1');
ddiff --git a/test/menu-feeds-chrome.test.js b/test/menu-feeds-chrome.test.js
iindex 4949538..d798253 100644
---- a/test/menu-feeds-chrome.test.js
++++ b/test/menu-feeds-chrome.test.js
@@@ -108,13 +108,15 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
const box = await p.evaluate(`document.documentElement.scrollWidth`);
assert.ok(box <= 390, `nothing runs off a phone (${box})`);
-- // Reopened later: the choice holds.
++ // Reopened later: the menu opens on this site's tab (4.189.0: no tab is remembered), and the Feed keeps its order.
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), true`);
await wait(200);
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
await wait(150);
-- assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'feed', 'it reopens on the tab last chosen');
++ assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'here', 'it reopens on this site\'s tab, whatever was open last');
++ await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
++ await wait(250);
assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');
// Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.
const ctx2 = await browser.newContext();
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex e0b99bf..0111ef9 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -223,14 +223,23 @@ function load(hostname, opts = {}) {
] });
const nav = created.find(e => e.tag === 'nav');
assert.ok(/class="ovnav-chip ovnav-chip--all" id="openvibe-chips-all"/.test(nav.innerHTML), 'the folded chip');
-- assert.ok(/data-chip-mini="coins" data-tone="gold">.*?<span class="ovnav-chip-mv">161k<\/span>/.test(nav.innerHTML), '160,774 reads 161k');
-- assert.ok(/data-chip-mini="vibes" data-tone="green">.*?<span class="ovnav-chip-mv">400<\/span>/.test(nav.innerHTML));
++ assert.ok(/class="ovnav-chip-mini is-on" data-chip-mini="coins" data-v="160,774" data-tone="gold">.*?<span class="ovnav-chip-mv">161k<\/span>/.test(nav.innerHTML), '160,774 reads 161k, and the first balance is the one showing');
++ assert.ok(/class="ovnav-chip-mini" data-chip-mini="vibes" data-v="400" data-tone="green">.*?<span class="ovnav-chip-mv">400<\/span>/.test(nav.innerHTML), 'the second waits in the same place');
assert.ok(nav.innerHTML.includes('aria-label="Your balances: OpenCoins 160,774, Vibes balance 400"'), 'said in full to a screen reader');
-- assert.ok(/@media \(max-width: 560px\) \{\s*\.openvibe-navbar-right\.has-chips-all \.ovnav-chip\[data-chip-id\] \{ display: none; \}/.test(require('fs').readFileSync(NAVBAR_PATH, 'utf8')), 'the chips fold on a phone');
-- // One chip: nothing to fold.
++ {
++ // One pill at every width (4.189.0): the chips it stands for are not drawn beside it, and purse.js is loaded for it.
++ const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');
++ assert.ok(/\n {12}\.openvibe-navbar-right\.has-chips-all \.ovnav-chip\[data-chip-id\] \{ display: none; \}/.test(src) && !/@media \(max-width: 560px\) \{\s*\.openvibe-navbar-right\.has-chips-all/.test(src), 'one pill on a phone and on a computer');
++ assert.ok(src.includes(".ovnav-chip-mini { grid-area: 1 / 1;") && src.includes(".ovnav-chip-mini.is-on { opacity: 1; }"), 'the balances share one place, one showing');
++ assert.ok(src.includes("sc.src = siblingV('purse.js')") && src.includes("root.OpenVibePurse.set(id, value)"), 'purse.js draws the icons and hears each change');
++ }
++ // A site's single counter stays a chip; a single balance is the pill (it has its own icon there).
const one = load('openvibe.live');
-- one.navbar.init({ service: 'live', wallet: false, user: { id: 1, username: 'x' }, chips: [{ id: 'coins', icon: 'fa-coins', value: '5' }] });
++ one.navbar.init({ service: 'live', wallet: false, user: { id: 1, username: 'x' }, chips: [{ id: 'viewers', icon: 'fa-eye', value: '5' }] });
assert.ok(!/ovnav-chip--all/.test(one.created.find(e => e.tag === 'nav').innerHTML));
++ const coin = load('openvibe.live');
++ coin.navbar.init({ service: 'live', wallet: false, user: { id: 1, username: 'x' }, chips: [{ id: 'coins', icon: 'fa-coins', value: '5' }] });
++ assert.ok(/id="openvibe-chips-all"/.test(coin.created.find(e => e.tag === 'nav').innerHTML));
}
// ── "Across OpenVibe" in the drawer, and the icon slots that have their size before ov-icons.js arrives ──
{
ddiff --git a/test/navbar-phone-fit-chrome.test.js b/test/navbar-phone-fit-chrome.test.js
iindex e76d491..e8a31b4 100644
---- a/test/navbar-phone-fit-chrome.test.js
++++ b/test/navbar-phone-fit-chrome.test.js
@@@ -48,7 +48,8 @@ const MEASURE = `(() => {
const parts = [['name', name], ['launcher', bar.querySelector('.ovnav-launch')], ['search', bar.querySelector('#openvibe-search-btn')],
['balances', bar.querySelector('.ovnav-chip--all')], ['menu', bar.querySelector('#openvibe-burger')],
['sign in', bar.querySelector('#openvibe-login-btn')], ['picture', bar.querySelector('#openvibe-avatar-btn')]].filter(([, el]) => vis(el)).map(([k, el]) => [k, box(el)]);
-- const minis = [...bar.querySelectorAll('.ovnav-chip--all .ovnav-chip-mini')].filter(vis).length;
++ const all = [...bar.querySelectorAll('.ovnav-chip--all .ovnav-chip-mini')];
++ const minis = all.filter((m) => getComputedStyle(m).opacity === '1').length + ' of ' + all.length;
const menu = bar.querySelector('#openvibe-burger');
const orbMenu = !!(menu && menu.classList.contains('ovnav-orb') && menu.querySelectorAll('.ovmb .ovmb-app').length === 3 && !!menu.querySelector('.ovmb-bar') && !menu.querySelector('span'));
return { parts, clipped: name.scrollWidth > name.clientWidth + 1, visibleText: name.innerText, minis, width: innerWidth, orbMenu, docW: document.documentElement.scrollWidth };
@@@ -76,7 +77,7 @@ const MEASURE = `(() => {
const last = v.parts[v.parts.length - 1];
assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);
if (width >= 360) assert.ok(!v.clipped && /Games/.test(v.visibleText), `${at}: the site's word reads whole (${v.visibleText})`);
-- if (who === 'in') assert.strictEqual(v.minis, 2, `${at}: the folded balances show both, OpenCoins and Vibes (4.25.0)`);
++ if (who === 'in') assert.strictEqual(v.minis, '1 of 2', `${at}: one pill, one balance showing, the other a turn away (4.189.0)`);
// One menu button on a phone (4.137.0, the owner: "these buttons do the same shit"): the burger opens the
// OpenVibe menu, and the orb that opened the same menu steps aside.
assert.ok(v.parts.some(([k]) => k === 'menu'), `${at}: the menu button is there`);
ddiff --git a/test/navbar-wallet-chips.test.js b/test/navbar-wallet-chips.test.js
iindex 6f391f2..5987cad 100644
---- a/test/navbar-wallet-chips.test.js
++++ b/test/navbar-wallet-chips.test.js
@@@ -59,11 +59,18 @@ const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.ne
assert(!calls.some((u) => u.includes('/api/coins/me') || u.includes('/api/me/vibes')));
const html = navs[navs.length - 1].innerHTML;
assert.match(html, /160,799/); assert.match(html, /400/);
++ // Only the balances the person has, the largest first (4.189.0): no money balance, so it is not in the pill.
assert.deepStrictEqual(navbar._wallet.walletOrder(data), ['coins', 'vibes']);
data.used_ai_this_month = true;
-- assert.deepStrictEqual(navbar._wallet.walletOrder(data), ['balance', 'coins']);
-- data.balances.coins.amount = '0'; data.balances.vibes.amount = '0';
++ assert.deepStrictEqual(navbar._wallet.walletOrder(data), ['balance', 'coins', 'vibes'], 'AI was used and the money ran out: that leads');
++ data.balances.balance.amount = '12500000000';
++ assert.deepStrictEqual(navbar._wallet.walletOrder(data), ['coins', 'balance', 'vibes'], 'all three, by worth (400 Vibes are 4 dollars)');
++ data.balances.vibes.amount = '0';
++ assert.deepStrictEqual(navbar._wallet.walletOrder(data), ['coins', 'balance'], 'no Vibes: not shown');
++ data.balances.coins.amount = '0'; data.balances.balance.amount = '0';
assert.strictEqual(navbar._wallet.walletOrder(data)[0], 'balance');
++ data.used_ai_this_month = false;
++ assert.deepStrictEqual(navbar._wallet.walletOrder(data), ['coins'], 'nothing at all: OpenCoins at zero, alone');
navbar.setUser(null, null); await settle();
assert(!navs[navs.length - 1].innerHTML.includes('data-chip-id="coins"'));
ddiff --git a/test/navbar-yours-everywhere.test.js b/test/navbar-yours-everywhere.test.js
iindex 9bfbada..5a9e465 100644
---- a/test/navbar-yours-everywhere.test.js
++++ b/test/navbar-yours-everywhere.test.js
@@@ -65,8 +65,8 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));
assert.doesNotMatch(bar, /openvibe-messages-btn|ovnav-msgs/, 'no Messages button in the bar: the bell counts them');
assert.ok(bar.indexOf('id="openvibe-bell-mount"') < bar.indexOf('id="openvibe-avatar-btn"'), 'the bell, then the person: the picture is the last thing in the bar');
assert.match(bar, /data-chip-id="coins"/, 'the OpenCoins chip');
-- assert.match(bar, /data-chip-id="vibes"/, 'the Vibes chip');
await settle();
++ assert.match(navs[navs.length - 1].innerHTML, /data-chip-id="vibes"/, 'the Vibes chip, once the wallet says there are Vibes (4.189.0: only the balances a person has)');
assert.deepStrictEqual(asked(calls, /\/api\/dm\/unread$/), ['https://openvibe.chat/api/dm/unread'], 'Chat is asked for the count');
assert.strictEqual(asked(calls, /\/api\/frame\/wallet\/api\/v1\/me\/wallet$/).length, 1, 'and the Network (its wallet relay) for the balances');
assert.deepStrictEqual(bell, [3], 'the count goes to the bell');
ddiff --git a/test/search-loading-chrome.test.js b/test/search-loading-chrome.test.js
iindex 3ed5945..c7da782 100644
---- a/test/search-loading-chrome.test.js
++++ b/test/search-loading-chrome.test.js
@@@ -95,7 +95,7 @@ const server = http.createServer((req, res) => {
// 4.149.0 (the owner, 11-08: "tabs at the top … the main tab should just show links that exist on that site, and
// under that the full list of sites"): this site's tab, Feed and You; nothing typed opens on this site's tab.
assert.strictEqual(v.tabs, 3, 'three tabs at the top');
-- assert.deepStrictEqual([v.groups[0], v.links], ['On Chat', ['Home', 'Rooms', 'Servers']], 'nothing typed: this site\'s own pages first, Home leading them (no link names the home)');
++ assert.deepStrictEqual([v.groups[0], v.links], ['Pages on Chat', ['Home', 'Rooms', 'Servers']], 'nothing typed: this site\'s own pages first, Home leading them (no link names the home)');
assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'then every site, even before the Frame answers');
assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.