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)
Changes
@@@ -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 →@@@ -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 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.@@@ -831,7 +831,20 @@ One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menuon its first tab, as the menu button does (there is no separate siteswitcher). The site's name stays the link home; switcher: false keeps themark 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 menuon 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,@@@ -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)",@@@ -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)); });@@@ -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@@@ -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"): onecalm 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 pilltightens 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 belland the picture). Up to 480px the two balances stack in one pill, each with its icon (both still show, as askedon 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(', ')}`);@@@ -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",@@@ -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",nnew file mode 100644@@@ -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);@@@ -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'] },@@@ -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();@@@ -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');@@@ -108,13 +108,15 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neconst 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();@@@ -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 ──{@@@ -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`);@@@ -59,11 +59,18 @@ const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.neassert(!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"'));@@@ -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');@@@ -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.
@@ -1,5 +1,36 @@
@@ -1,5 +1,36 @@
# Changelog
# 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
## 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 →
**The account's things are in the menu.** The owner, 2026-11-10, with the dropdown under the picture marked "Replace →
@@ -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.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
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.
@@ -831,7 +831,20 @@ One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menu
@@ -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
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
switcher). The site's name stays the link home; switcher: false keeps the
mark as a home link.
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,
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,
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,
Display). There is no dropdown under the picture; the block rests in the bar,
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.188.0",
+ "version": "4.189.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)",
@@ -163,6 +163,8 @@
@@ -163,6 +163,8 @@
@keyframes ovdk-grow { from { opacity: 0; transform: scale(.82); } }
@keyframes ovdk-grow { from { opacity: 0; transform: scale(.82); } }
@keyframes ovdk-rise { from { transform: translateY(100%); } }
@keyframes ovdk-rise { from { transform: translateY(100%); } }
.ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.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-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 { 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; }
.ovdk-tabs::-webkit-scrollbar { display: none; }
@@ -171,7 +173,7 @@
@@ -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; }
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 svg { width: 17px; height: 17px; flex: none; }
.ovdk-tabs button:hover { color: var(--text-primary, #ececf1); }
.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[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 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;
.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 @@
@@ -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 { 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: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 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 svg { width: 22px !important; height: 22px !important; overflow: visible; }
.ovdk-orbx { --ovo-a: var(--accent-light, var(--accent, #60a5fa)); }
.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; }
.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 @@
@@ -317,6 +320,17 @@
.ovdk-tabs button svg { width: 19px; height: 19px; }
.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-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; }
.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) {
@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;
.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 @@
@@ -348,6 +362,7 @@
<header class="ovdk-head">
<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-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">
<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="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>
<button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX()}</button>
</div>
</div>
@@ -369,6 +384,8 @@
@@ -369,6 +384,8 @@
if (j >= 0) { e.preventDefault(); setTab(tabs[j].dataset.tab, true); tabs[j].focus(); }
if (j >= 0) { e.preventDefault(); setTab(tabs[j].dataset.tab, true); tabs[j].focus(); }
});
});
el.querySelector('[data-act="close"]').addEventListener('click', () => close());
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="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
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)); });
el.querySelector('[data-act="timer"]').addEventListener('click', () => { const a = el.querySelector('[data-act="more"]'); closeMore(); withTimer((t) => t.menu(a)); });
@@ -25,7 +25,7 @@ const BROWSER = Object.freeze([
@@ -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.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
'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
'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-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-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
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
@@ -175,13 +175,14 @@
@@ -175,13 +175,14 @@
switcher next to the logo); a tap lists each in full. */
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
/* 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. */
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); }
.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[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;
.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); }
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; }
.ovnav-chips-pop.open { display: flex; animation: openvibe-slide-down .18s ease; }
@@ -190,10 +191,6 @@
@@ -190,10 +191,6 @@
.ovnav-chips-pop button .ovnav-ic { color: var(--chip-c, inherit); width: 16px; height: 16px; }
.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 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; }
.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: 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; } }
@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
/* 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 @@
@@ -204,8 +201,7 @@
.openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }
.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
/* 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. */
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 { 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; } }
.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). */
/* Under 350px the mark alone names the site, so both balances keep their icons (a number alone says nothing). */
@@ -214,12 +210,6 @@
@@ -214,12 +210,6 @@
website you're on seems to get cut off", openvibe.live showing "Li…" beside 161k | 400, the messages, the bell
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
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. */
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 .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; }
.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. */
/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
@@ -558,7 +548,7 @@
@@ -558,7 +548,7 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// 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': '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
// 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
// (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">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1057,7 +1047,6 @@
@@ -1057,7 +1047,6 @@
return String(Math.round(n));
return String(Math.round(n));
}
}
const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0];
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
// 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.
// 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
let _accountEl = null; // the account's block, built at render, hosted by the menu's You tab
@@ -1074,13 +1063,15 @@
@@ -1074,13 +1063,15 @@
function chipsAllHTML(chips) {
function chipsAllHTML(chips) {
const list = (Array.isArray(chips) ? chips : []).filter((c) => c && !c.hidden);
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) {
function bindChipsAll(nav) {
const btn = nav.querySelector('#openvibe-chips-all'); if (!btn || typeof btn.addEventListener !== 'function') return;
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;
const right = typeof btn.closest === 'function' ? btn.closest('.openvibe-navbar-right') : null;
if (right && right.classList) right.classList.add('has-chips-all');
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;
let pop = null;
const close = () => { if (pop) pop.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
const close = () => { if (pop) pop.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
btn.addEventListener('click', (e) => {
btn.addEventListener('click', (e) => {
@@ -2006,11 +1997,11 @@
@@ -2006,11 +1997,11 @@
}
}
function walletOrder(data) {
function walletOrder(data) {
const order = ['coins', 'vibes', 'balance'];
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);
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() {
function syncWalletChips() {
if (!_config.wallet) return;
if (!_config.wallet) return;
@@ -2309,6 +2300,7 @@
@@ -2309,6 +2300,7 @@
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
// 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); },
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
look: bindLook,
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.
// 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; },
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'); } });
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 @@
@@ -2612,6 +2604,7 @@
lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));
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-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); });
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); });
document.querySelectorAll(`[data-chip-row="${id}"] b`).forEach(el => { el.textContent = value == null ? '' : String(value); });
const all = document.getElementById('openvibe-chips-all');
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(', ')}`);
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(', ')}`);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.188.0",
+ "version": "4.189.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.188.0",
+ "version": "4.189.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.188.0",
+ "version": "4.189.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": {
@@ -49,6 +49,7 @@
@@ -49,6 +49,7 @@
"./action-center": "./action-center.js",
"./action-center": "./action-center.js",
"./dock": "./dock.js",
"./dock": "./dock.js",
"./presence": "./presence.js",
"./presence": "./presence.js",
+ "./purse": "./purse.js",
"./nav-slots": "./nav-slots.js",
"./nav-slots": "./nav-slots.js",
"./overlay": "./overlay.js",
"./overlay": "./overlay.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -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);
@@ -15,7 +15,9 @@ const ROOT = path.join(__dirname, '..');
@@ -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',
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',
'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.
// 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.
// 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'] },
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'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
@@ -66,11 +66,6 @@
@@ -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
// 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.
// of sites"). The first is labelled with this site's own name.
const TABS = [['here', ''], ['feed', 'Feed'], ['you', 'You']];
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
// 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").
// since last time you've looked… a badge").
const FEED_SEEN = 'ovs-feed-seen';
const FEED_SEEN = 'ovs-feed-seen';
@@ -218,7 +213,17 @@
@@ -218,7 +213,17 @@
.ovs-x span { display: none; }
.ovs-x span { display: none; }
.ovs-x:active { background: var(--bg-active, rgba(255,255,255,.14)); color: var(--text-primary, #fff); }
.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. */
/* 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 { 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); }
.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 @@
@@ -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 { 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-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-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-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 { 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)); }
.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 @@
@@ -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-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 .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-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 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 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); }
.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 @@
@@ -431,7 +450,7 @@
el.appendChild(orbEl);
el.appendChild(orbEl);
tabsEl.addEventListener('click', (e) => {
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
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 { /* */ }
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
if (touch) b.focus(); else input.focus();
if (touch) b.focus(); else input.focus();
});
});
@@ -715,7 +734,7 @@
@@ -715,7 +734,7 @@
if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
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); }
if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }
const ic = icon(it);
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);
a.append(ic, txt);
box.appendChild(a);
box.appendChild(a);
});
});
@@ -1010,8 +1029,29 @@
@@ -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); }
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);
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 */ } }
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. */
/** What every tab needs: the sites, this one, and the person's ranking of them. */
function basis() {
function basis() {
@@ -1060,13 +1100,14 @@
@@ -1060,13 +1100,14 @@
if (len > bestLen) { best = i; bestLen = len; }
if (len > bestLen) { best = i; bestLen = len; }
});
});
if (best >= 0) pages[best].here = true;
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' }];
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));
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 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);
const mini = (x) => siteItem(x, all);
fgroups.forEach((g, i) => {
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' });
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 : [];
const soon = f && Array.isArray(f.soon) ? f.soon : [];
@@ -1410,7 +1451,8 @@
@@ -1410,7 +1451,8 @@
// tab, whose second half is every site; "tools" is You); else this site.
// 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.
// "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];
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;
allOpen = false; hereOpen = true; leadHere = false; discoverPick = null;
drainCards();
drainCards();
syncFeeds();
syncFeeds();
@@ -75,8 +75,10 @@ async function openMenu(p) {
@@ -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');
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: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await p.send('Input.dispatchKeyEvent', { type: 'keyUp', 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');
assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'closing unlocks the page');
await openMenu(p);
await openMenu(p);
await p.evaluate('document.querySelector(".ovs.is-open .ovs-orb").click(); 1');
await p.evaluate('document.querySelector(".ovs.is-open .ovs-orb").click(); 1');
@@ -108,13 +108,15 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -108,13 +108,15 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
const box = await p.evaluate(`document.documentElement.scrollWidth`);
const box = await p.evaluate(`document.documentElement.scrollWidth`);
assert.ok(box <= 390, `nothing runs off a phone (${box})`);
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 p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), true`);
await wait(200);
await wait(200);
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
await wait(150);
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');
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.
// 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();
const ctx2 = await browser.newContext();
@@ -223,14 +223,23 @@ function load(hostname, opts = {}) {
@@ -223,14 +223,23 @@ function load(hostname, opts = {}) {
] });
] });
const nav = created.find(e => e.tag === 'nav');
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(/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(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');
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));
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 ──
// ── "Across OpenVibe" in the drawer, and the icon slots that have their size before ov-icons.js arrives ──
{
{
@@ -48,7 +48,8 @@ const MEASURE = `(() => {
@@ -48,7 +48,8 @@ const MEASURE = `(() => {
const parts = [['name', name], ['launcher', bar.querySelector('.ovnav-launch')], ['search', bar.querySelector('#openvibe-search-btn')],
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')],
['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)]);
['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 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'));
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 };
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 = `(() => {
@@ -76,7 +77,7 @@ const MEASURE = `(() => {
const last = v.parts[v.parts.length - 1];
const last = v.parts[v.parts.length - 1];
assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);
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 (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
// 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.
// 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`);
assert.ok(v.parts.some(([k]) => k === 'menu'), `${at}: the menu button is there`);
@@ -59,11 +59,18 @@ const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.ne
@@ -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')));
assert(!calls.some((u) => u.includes('/api/coins/me') || u.includes('/api/me/vibes')));
const html = navs[navs.length - 1].innerHTML;
const html = navs[navs.length - 1].innerHTML;
assert.match(html, /160,799/); assert.match(html, /400/);
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']);
assert.deepStrictEqual(navbar._wallet.walletOrder(data), ['coins', 'vibes']);
data.used_ai_this_month = true;
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');
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();
navbar.setUser(null, null); await settle();
assert(!navs[navs.length - 1].innerHTML.includes('data-chip-id="coins"'));
assert(!navs[navs.length - 1].innerHTML.includes('data-chip-id="coins"'));
@@ -65,8 +65,8 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));
@@ -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.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.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="coins"/, 'the OpenCoins chip');
- assert.match(bar, /data-chip-id="vibes"/, 'the Vibes chip');
await settle();
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.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.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');
assert.deepStrictEqual(bell, [3], 'the count goes to the bell');
@@ -95,7 +95,7 @@ const server = http.createServer((req, res) => {
@@ -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
// 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.
// 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.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.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)');
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.
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.