openvibers/openvibe.shared Commits

4.194.0: one row of places in the menu, a wallet that is there at once, a Sign in that says so (#423)

OP OpenVibers committed 3 days ago · 8d0ed6e7f7 · 13 files, +184 −38

* The wallet sheet is there at once and nothing jumps

It opens holding what it last showed in this tab (or room held for its three cards), its other parts stand as placeholders of their size, the store, kinds and items are asked side by side with the balances, and each part arrives where it stands, once. The owner, 2026-11-10: "load faster and dynamically sync smarter … isn't so jumpy as it loads in".

* The menu's head is one row of five places: this site, Feed, Messages, AI, You

The owner, 2026-11-10, with the menu and the Dock side by side: "combine and consolidate these two menus into one unified menu … It just seems a little much with all the buttons on the first one … the agents and AI tools versus the feed and kind of community stuff". Three tabs with a second row of four under them become one row: Messages and AI (Vibe and your agents) are in it, between Feed and You, and open the Dock on those places until their panels move into this sheet. The Dock draws Vibe as the spark and Agents as a robot, as the row does (the spark meant two things). test/menu-places-chrome.test.js.

* 4.194.0: one row of places in the menu, a wallet that is there at once, a Sign in that says so

Sign in's figure and an arrow coming in take turns; the tooltip's file is fetched by the first pointer over the bar. Release files for 4.194.0.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8d22279..aeacf00 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,29 @@
# Changelog
++## 4.194.0 — 2026-11-10
++
++**One row of places in the menu, a wallet that is there at once, and a Sign in that says so.** From the owner's notes
++of 2026-11-10.
++
++- **The menu's head is one row of five places** ("combine and consolidate these two menus into one unified menu … It
++ just seems a little much with all the buttons on the first one … the agents and AI tools versus the feed and kind of
++ community stuff"). This site · Feed · Messages · AI · You, in place of three tabs with a second row of four under
++ them: on a phone each is its icon over its word, on a computer side by side. Messages and AI (Vibe and your agents)
++ open the Dock on those places for now; their panels move into this sheet next. A visitor has this site, Feed and
++ You, as before. The Dock draws Vibe as the spark and Agents as a robot, as the row does (the spark meant two things).
++- **The wallet sheet is there at once and nothing jumps** ("load faster and dynamically sync smarter … isn't so jumpy
++ as it loads in, the different elements and balances"). It opens holding what it last showed in this tab, or room
++ held for its three cards; the store and your items stand as placeholders of their size; the store, the kinds and the
++ items are asked side by side with the balances (they waited for them before); each part arrives where it stands,
++ once. What it keeps is the wallet's own answer in the person's tab, forgotten when the account changes.
++- **Sign in says "sign in"** ("should represent … that they need to sign in"). In the filled circle the figure and an
++ arrow coming in through a door take turns (the arrow for a moment every six seconds); under the pointer it is the
++ arrow, what a press does. Off shows the figure and does not move.
++- **The tooltip's file is fetched by the first pointer over the bar**, not with the page (it was fetched at once on
++ any device that can hover); what that pointer rests on gets its tooltip when the file arrives.
++- Tests: `menu-places-chrome` (five places in one row signed in, the doors open the Dock, three for a visitor),
++ the wallet's and the bar's. `dock.js` is 20.30 KB brotli (budget raised by 0.05 KB for the robot glyph, dated).
++
## 4.193.0 — 2026-11-10
**The bar's right end, one tooltip, and one frame for mail.** From the owner's notes of 2026-11-10.
ddiff --git a/README.md b/README.md
iindex 8b79b3c..3cbb61b 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.193.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.194.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex b103a3e..b5a43d0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.193.0",
++ "version": "4.194.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/dock.js b/dock.js
iindex 0ea49b4..e85236b 100644
---- a/dock.js
++++ b/dock.js
@@@ -52,7 +52,8 @@
clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',
};
// Vibe first (L48 V2): the person's AI, on every site.
-- const TABS = [['vibe', 'Vibe', G.vibe], ['agent', 'Agents', G.spark], ['chats', 'Messages', G.chat], ['activity', 'Activity', G.bell]];
++ const ROBOT = '<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13h.01M14.5 13h.01M3 12.5v2M21 12.5v2"/>'; // as in the menu's row
++ const TABS = [['vibe', 'Vibe', G.spark], ['agent', 'Agents', ROBOT], ['chats', 'Messages', G.chat], ['activity', 'Activity', G.bell]];
// The tabs the Network keeps in the person's Dock state (network.dock-state@1; vibe since contracts 0.270.0); a
// newer one stays on this site's copy until the Network learns it.
const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];
ddiff --git a/navbar.js b/navbar.js
iindex 71cd45b..1b737b3 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -89,7 +89,6 @@
.openvibe-navbar .openvibe-navbar-brand .name a.b-tld { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; box-sizing: border-box; }
.openvibe-navbar .openvibe-navbar-brand .flame { position: relative; }
.openvibe-navbar .openvibe-navbar-brand .flame::after { content: ''; position: absolute; inset: -8px -4px -8px -12px; }
-- /* 4.46.0 (the fleet tap audit): Sign In, the menu and the orb reach 44px to a finger, still drawn as before. */
.openvibe-navbar .openvibe-navbar-login, .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-orb { position: relative; }
/* Sign in is a 36px circle: its taps grow to the right and a little to the left, off the menu button's. */
.openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -5px -8px -5px -3px; }
@@@ -354,18 +353,21 @@
}
.openvibe-navbar-avatar:hover { border-color: var(--accent, #3b82f6); }
-- /* Sign in is the person's place before there is a person: a filled circle with a figure in it, named by its
-- tooltip (ov-tip.js). --accent-strong / --on-accent-strong read at 4.5:1 or better in every theme. */
++ /* Sign in: a filled circle where the person's picture will be; a figure and an arrow coming in take turns. */
.openvibe-navbar-login { display: inline-grid; place-items: center; background: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); border: none; cursor: pointer; text-decoration: none; transition: filter .15s, box-shadow .25s, transform .18s cubic-bezier(.2, 1.4, .3, 1); }
.openvibe-navbar-login:hover, .openvibe-navbar-login:focus-visible { filter: brightness(1.12); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent); }
.openvibe-navbar-login:active { transform: scale(.9); }
.ovsi { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; overflow: visible; }
-- .ovsi-p { transform-origin: 12px 13px; animation: ovsi-in .5s cubic-bezier(.2, 1.4, .3, 1) .3s both; transition: transform .2s; }
-- :hover > .ovsi .ovsi-p { transform: translateY(calc(-1px * var(--ov-move, 1))); }
++ .ovsi-p { animation: ovsi-p 6s ease-in-out 2s infinite; }
++ .ovsi-a { opacity: 0; animation: ovsi-a 6s ease-in-out 2s infinite; }
++ :hover > .ovsi .ovsi-p { animation: none; opacity: 0; }
++ :hover > .ovsi .ovsi-a { animation: none; opacity: 1; }
++ html[data-ov-motion=off] .ovsi :is(.ovsi-p, .ovsi-a) { animation: none; }
.ovsi-o { opacity: 0; stroke-dasharray: 30 39; }
.is-going .ovsi-o { opacity: 1; animation: ovsi-go 1s linear infinite; }
.is-going .ovsi-p { opacity: .55; }
-- @keyframes ovsi-in { from { opacity: 0; transform: translateY(4px) scale(.8); } }
++ @keyframes ovsi-p { 0%, 62%, 100% { opacity: 1; transform: none; } 70%, 92% { opacity: 0; transform: translateX(4px); } }
++ @keyframes ovsi-a { 0%, 62%, 100% { opacity: 0; transform: translateX(-5px); } 70%, 92% { opacity: 1; transform: none; } }
@keyframes ovsi-go { to { stroke-dashoffset: -69; } }
/* 4.148.0 (the owner, 11-08: "the things need to be more consistent in the nav bar in size"): one height for
every control at the bar's right, 36 px (34 px on a phone under 400 px): Sign In, the orb, messages, the
@@@ -563,7 +565,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': '04aa1cedb6b4', 'dock.js': '5754cf36a7de', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'c38f5859fff6', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5af39638e579', 'ov-wallet.css': 'bd92a321ae64', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '6ac08ce47825' };
++ 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': '3174ce995380', 'dock.js': '1e2c189c1b1e', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'c38f5859fff6', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '1d918e7de69d' };
// 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">.
@@@ -2116,7 +2118,7 @@
<div id="openvibe-bell-mount"></div>
${links.length ? `<button type="button" class="ovnav-burger ovnav-orb" id="openvibe-burger" aria-label="Menu: this site, search and every OpenVibe site" data-ov-tip="Menu" data-ov-tip-sub="This site, search, every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>` : ''}
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
-- `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}" aria-label="Sign in" data-ov-tip="Sign in"><svg class="ovsi" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><circle class="ovsi-o" cx="12" cy="12" r="11"/><g class="ovsi-p"><circle cx="12" cy="8.8" r="3.2"/><path d="M5.900 18.400c1.100-2.700 3.300-4.100 6.100-4.100s5 1.400 6.100 4.100"/></g></svg></a>`}
++ `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}" aria-label="Sign in" data-ov-tip="Sign in"><svg class="ovsi" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><circle class="ovsi-o" cx="12" cy="12" r="11"/><g class="ovsi-p"><circle cx="12" cy="8.8" r="3.2"/><path d="M5.900 18.400c1.100-2.700 3.300-4.100 6.100-4.100s5 1.400 6.100 4.100"/></g><path class="ovsi-a" d="M5.500 12h8.500M11 8.500l3.500 3.500-3.500 3.500M15.500 6.500h2.500v11h-2.500"/></svg></a>`}
</div>
${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${drawerOrder(links).map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div>${u ? '' : '<div class="ovnav-display" hidden></div>'}</div>` : ''}
`;
@@@ -2124,7 +2126,9 @@
bindLinks(nav, links);
hookSearch(nav);
hookSwitcher(nav); hookPages(nav); hookSignIn(nav); hookFit(nav);
-- if (!root.OVTip && !document.getElementById('ov-tip-loader') && root.matchMedia && matchMedia('(hover: hover)').matches) { try { const sc = document.createElement('script'); sc.id = 'ov-tip-loader'; sc.src = siblingV('ov-tip.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ } }
++ // The tooltip's file is fetched when a pointer first comes over the bar, not with the page.
++ const tip = () => { nav.removeEventListener('pointerover', tip); if (!root.OVTip && !document.getElementById('ov-tip-loader')) { try { const sc = document.createElement('script'); sc.id = 'ov-tip-loader'; sc.src = siblingV('ov-tip.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ } } };
++ if (typeof nav.addEventListener === 'function') nav.addEventListener('pointerover', tip, { passive: true });
bindChips(nav);
bindChipsAll(nav);
queueIcons();
ddiff --git a/ov-tip.js b/ov-tip.js
iindex 5d4b356..e5050c8 100644
---- a/ov-tip.js
++++ b/ov-tip.js
@@@ -92,4 +92,6 @@ html[data-ov-motion=off] .ovtip { transform: none; transition: none; }
root.addEventListener('resize', hide, { passive: true });
root.OVTip = { show, hide };
++ // Fetched by the first pointer over the bar: what that pointer is resting on gets its tooltip.
++ try { const now = document.querySelector('[data-ov-tip]:hover'); if (now) timer = setTimeout(() => show(now), 240); } catch { /* an old engine without :hover here */ }
}(typeof window !== 'undefined' ? window : globalThis));
ddiff --git a/ov-wallet.css b/ov-wallet.css
iindex 08c37ba..fc9f482 100644
---- a/ov-wallet.css
++++ b/ov-wallet.css
@@@ -34,3 +34,23 @@
.ov-wallet-balance[data-purse]>.ov-wallet-button{grid-column:3;grid-row:1/3}
.ov-wallet-balance[data-purse] .ov-wallet-purpose{grid-column:1/-1;grid-row:3;margin-top:6px}}
html[data-ov-motion=off] .ov-wallet-balance[data-purse],html[data-ov-motion=off] .ov-wallet-balance[data-purse]::after{animation:none}
++
++
++/* There at once, and nothing jumps (4.193.0). Until a part answers, a placeholder of its size stands in its place; the
++ parts arrive where they stand, once, as the sheet opens. */
++.ov-wallet-wait-set { display: grid; gap: 12px; }
++.ov-wallet-wait { border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); animation: ov-wallet-wait 1.3s ease-in-out infinite alternate; }
++.ov-wallet-wait.is-card { height: 148px; }
++.ov-wallet-wait.is-row { height: 64px; border-radius: 12px; }
++.ov-wallet-wait.is-tile { height: 132px; }
++.ov-wallet-grid > .ov-wallet-wait-set { grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); }
++@keyframes ov-wallet-wait { from { opacity: .55; } to { opacity: 1; } }
++.ov-wallet-body.is-first > * { animation: ov-wallet-part .32s cubic-bezier(.2, .7, .2, 1) both; }
++.ov-wallet-body.is-first > :nth-child(3) { animation-delay: .05s; }
++.ov-wallet-body.is-first > :nth-child(4) { animation-delay: .1s; }
++.ov-wallet-body.is-first > :nth-child(n + 5) { animation-delay: .15s; }
++@keyframes ov-wallet-part { from { opacity: 0; transform: translateY(8px); } }
++html[data-ov-motion=reduced] .ov-wallet-body.is-first > * { animation: ov-wallet-fade .2s ease both; animation-delay: 0s; }
++html[data-ov-motion=off] .ov-wallet-body.is-first > *, html[data-ov-motion=off] .ov-wallet-wait { animation: none; }
++@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ov-wallet-body.is-first > * { animation: ov-wallet-fade .2s ease both; animation-delay: 0s; } }
++@keyframes ov-wallet-fade { from { opacity: 0; } }
ddiff --git a/ov-wallet.js b/ov-wallet.js
iindex fee2190..5078c74 100644
---- a/ov-wallet.js
++++ b/ov-wallet.js
@@@ -23,6 +23,14 @@
}
return hosts.has(url.hostname.toLowerCase());
}
++ // What the sheet last showed in this tab (the wallet's own answer: the person's balances and recent lines, kept only
++ // in their tab): the next opening starts from it,
++ // so the cards are right from the first frame. Forgotten when the account changes.
++ const KEEP = 'ov-wallet-last';
++ const kept = () => { try { const j = JSON.parse(root.sessionStorage.getItem(KEEP) || 'null'); return j && j.balances && j.subject && j.subject.id ? j : null; } catch { return null; } };
++ const keep = (d) => { try { root.sessionStorage.setItem(KEEP, JSON.stringify(d)); } catch { /* a closed store: it opens with placeholders */ } };
++ if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('openvibe-auth-changed', () => { try { root.sessionStorage.removeItem(KEEP); } catch { /* */ } });
++
async function open(options) {
close();
const token = (options.getToken ? options.getToken() : options.token) || null;
@@@ -40,13 +48,15 @@
heading.lastChild.id = 'ov-wallet-title';
const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
header.append(heading, exit);
-- const body = el('div', 'ov-wallet-body'), status = el('p', 'ov-wallet-status', 'Opening your wallet…');
++ const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', '');
status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body);
const previousFocus = document.activeElement, previousOverflow = document.documentElement.style.overflow;
// A site's surfaces.js locks the page for any modal dialog and restores it on close; locking here as well would
// make its saved state "hidden". Only a page without it needs the wallet's own lock.
const ownLock = !root.OVSurfaces;
let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [];
++ const here = { store: false, items: false }; // which parts have answered: until then a placeholder of their size stands in
++ const wait = (n, cls) => { const w = el('div', 'ov-wallet-wait-set'); w.setAttribute('aria-hidden', 'true'); for (let i = 0; i < n; i += 1) w.append(el('div', `ov-wallet-wait ${cls || ''}`)); return w; };
const shown = {}; // what each balance last showed, so a change counts from it
const pending = new Map();
const session = { close() {
@@@ -149,6 +159,8 @@
});
body.prepend(form); form.scrollIntoView({ block: 'start' }); input.focus();
}
++ // Before anything is known (the first opening in a tab): three cards' worth of room, so nothing moves when they come.
++ function hold() { body.replaceChildren(status, wait(3, 'is-card'), wait(2, 'is-row')); status.textContent = 'Opening your wallet…'; }
function render() {
body.replaceChildren(status); status.textContent = data.money_writes_frozen ? 'Purchases and top-ups are paused. Your balances and items are still here.' : '';
status.classList.remove('is-error');
@@@ -178,7 +190,8 @@
}
const store = section('Store', 'A little more you. Items travel with your OpenVibe account.');
const grid = el('div', 'ov-wallet-grid'); store.append(grid);
-- if (!storeItems.length) grid.append(el('p', 'ov-wallet-muted', 'New items will appear here after review.'));
++ if (!here.store) grid.append(wait(2, 'is-tile'));
++ else if (!storeItems.length) grid.append(el('p', 'ov-wallet-muted', 'New items will appear here after review.'));
for (const item of storeItems) {
const card = el('article', 'ov-wallet-item'); card.append(preview(item.preview, item.name), el('h4', '', item.name), el('p', 'ov-wallet-muted', `By ${maker(item.issuer)}`));
const owned = item.owned || (allItems && allItems.instances.some((i) => i.definition_id === item.id && i.state === 'owned'));
@@@ -196,7 +209,8 @@
}
const mine = section('Your items', 'Wear them wherever their kind is supported.');
const owned = allItems ? allItems.instances.filter((i) => i.state === 'owned') : [];
-- if (!owned.length) mine.append(el('p', 'ov-wallet-muted', 'Your collection starts here.'));
++ if (!here.items) mine.append(wait(2, 'is-row'));
++ else if (!owned.length) mine.append(el('p', 'ov-wallet-muted', 'Your collection starts here.'));
for (const item of owned) {
const def = allItems.definitions[item.definition_id]; if (!def) continue;
const kind = kinds.find((k) => k.id === def.kind), row = el('article', 'ov-wallet-owned-row'), copy = el('div');
@@@ -237,13 +251,24 @@
dialog.addEventListener('click', (e) => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) session.close(); });
document.addEventListener('openvibe-auth-changed', session.close);
document.body.append(dialog); dialog.showModal(); if (ownLock) document.documentElement.style.overflow = 'hidden'; exit.focus();
++ // There at once: what this tab last showed, or room held for it. Then everything is asked side by side (the
++ // store and the kinds need nothing; the items need to know whose they are, which a kept wallet already says).
++ const seed = kept();
++ if (seed) { data = seed; render(); } else hold();
++ setTimeout(() => body.classList.remove('is-first'), 600);
++ const settled = (pr) => pr.then((value) => ({ status: 'fulfilled', value }), (reason) => ({ status: 'rejected', reason }));
++ const storeAsked = settled(request('/api/v1/store')), kindsAsked = settled(request('/api/v1/kinds', 'GET', null, true));
++ let itemsAsked = seed ? settled(refreshItems()) : null;
try {
await refreshWallet();
-- const results = await Promise.allSettled([request('/api/v1/store'), refreshItems(), request('/api/v1/kinds', 'GET', null, true)]);
++ identity(); keep(data); render();
++ if (!itemsAsked || (seed && seed.subject.id !== data.subject.id)) itemsAsked = settled(refreshItems());
++ const results = await Promise.all([storeAsked, itemsAsked, kindsAsked]);
identity(); storeItems = results[0].status === 'fulfilled' ? results[0].value.items : [];
kinds = results[2].status === 'fulfilled' ? results[2].value.kinds : [];
++ here.store = here.items = true;
render(); if (results.some((r) => r.status === 'rejected')) message(new Error('Your balances are ready. The store or your items could not load. Close and reopen Wallet to retry.'));
-- } catch (error) { message(error); }
++ } catch (error) { if (seed && !disposed) { here.store = here.items = true; } message(error); }
return session;
}
const api = { open, close, amount, checkoutAllowed };
ddiff --git a/package-lock.json b/package-lock.json
iindex 19acca4..c7d6980 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.193.0",
++ "version": "4.194.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.193.0",
++ "version": "4.194.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 46b31c0..0e6df81 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.193.0",
++ "version": "4.194.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/search.js b/search.js
iindex 87bc036..11a9f3e 100644
---- a/search.js
++++ b/search.js
@@@ -214,7 +214,10 @@
.ovs-x:active { background: var(--bg-active, rgba(255,255,255,.14)); color: var(--text-primary, #fff); }
}
.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. */
++/* Five places in one row (4.194.0): on a phone each is its icon over its word; the doors to the Dock look like the tabs. */
++.ovs-starttabs.has-doors .ovs-tab { padding: 0 6px; }
++.ovs-starttabs .ovs-tab[data-dock] .ovs-tab-ic > svg { width: 20px; height: 20px; }
++@media (max-width: 640px) { .ovs-starttabs.has-doors { gap: 4px; padding: 2px 8px 8px; } .ovs-starttabs.has-doors .ovs-tab { flex-direction: column; gap: 3px; height: 54px; padding: 4px 2px; font-size: 11.5px; border-radius: 14px; } }
.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; }
@@@ -1011,9 +1014,24 @@
const me = ctx.token && ctx.user && !ctx.user.is_anon ? ctx.user : null;
let siteId = ctx.service || '';
try { const b0 = basis(); if (b0.mineSite) siteId = b0.mineSite.icon || b0.mineSite.id; } catch { /* the sites load later */ }
-- for (const [id, label] of TABS) {
++ // One row of places (4.194.0): this site, the feed, your messages, AI (Vibe and your agents), you. Messages and
++ // AI are the Dock's; they are in this row, between Feed and You, where the bar can open the Dock for this person.
++ const doors = typeof ctx.dock === 'function' ? [['chats', 'Messages'], ['vibe', 'AI']] : [];
++ const row = [TABS[0], TABS[1], ...doors.map(([id, label]) => [id, label, true]), TABS[2]];
++ tabsEl.classList.toggle('has-doors', doors.length > 0);
++ for (const [id, label, door] of row) {
const b = document.createElement('button');
-- b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab'); b.setAttribute('data-tab', id);
++ b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab');
++ if (door) {
++ // A place that opens in the Dock: never the chosen tab here; it says it opens something.
++ b.setAttribute('data-dock', id); b.setAttribute('aria-selected', 'false'); b.setAttribute('aria-haspopup', 'dialog');
++ const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);
++ const t = document.createElement('span'); t.textContent = label; b.appendChild(t);
++ b.addEventListener('click', (e) => { e.stopPropagation(); try { ctx.dock(id); } catch { /* the Dock is optional */ } });
++ tabsEl.appendChild(b);
++ continue;
++ }
++ b.setAttribute('data-tab', id);
b.setAttribute('aria-selected', tab === id ? 'true' : 'false');
const ic = document.createElement('span'); ic.className = 'ovs-tab-ic';
if (id === 'here' && siteId) { const g = document.createElement('span'); g.className = 'ov-icon'; g.setAttribute('data-icon', siteId); g.setAttribute('data-size', '20'); g.setAttribute('data-fx', 'none'); ic.appendChild(g); }
@@@ -1029,20 +1047,6 @@
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>`;
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex f4f1f11..e9f7076 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
--const DOCK_BROTLI_BUDGET = 20736; // 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
++const DOCK_BROTLI_BUDGET = 20787; // 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
// ── the budget ──────────────────────────────────────────────
ddiff --git a/test/menu-places-chrome.test.js b/test/menu-places-chrome.test.js
nnew file mode 100644
iindex 0000000..ceafc72
---- /dev/null
++++ b/test/menu-places-chrome.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++// The menu's head is one row of places (4.194.0) in a real headless Chrome. The owner, 2026-11-10, with the menu and the
++// Dock side by side: "combine and consolidate these two menus into one unified menu … It just seems a little much with
++// all the buttons on the first one". Where the bar can open the Dock for the person: this site, Feed, Messages, AI, You,
++// in that order, in ONE row, with no second row under it; Messages and AI open the Dock on those places and are never
++// the chosen tab here. For a visitor: this site, Feed, You, as before.
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const http = require('node:http');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-places-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Chat</title><link rel="icon" href="data:,"></head>
++<body style="margin:0;min-height:100vh;background:#0b1120"><script>window.fetch = async () => new Response('{}', { status: 404 });</script><script src="/search.js"></script></body></html>`;
++const server = http.createServer((req, res) => {
++ const u = req.url.split('?')[0];
++ if (u === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
++ const f = path.join(root, u.slice(1));
++ if (/^\/[a-z-]+\.js$/.test(u) && fs.existsSync(f)) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(f)); }
++ res.writeHead(404); res.end();
++});
++const OPEN = (dock) => `OpenVibeSearch.open({ service: 'chat', scope: { label: 'Chat', page: '/search' }, links: [{ label: 'Rooms', href: '/rooms' }], frame: {}, token: ${dock ? "'t'" : 'null'},
++ user: ${dock ? "{ id: 7, username: 'maya', display_name: 'Maya' }" : 'null'}, dock: ${dock ? '(id) => { window.went = id; }' : 'null'} })`;
++const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).map((t) => { const r = t.getBoundingClientRect(); return { label: t.textContent.trim(), chosen: t.getAttribute('aria-selected') === 'true', door: t.getAttribute('data-dock') || '', role: t.getAttribute('role'), top: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), right: Math.round(r.right) }; })`;
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-places-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ for (const [width, height, name] of [[390, 780, 'a phone'], [1200, 800, 'a computer']]) {
++ const page = await h.openPage(browser, await browser.newContext(), { width, height });
++ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 6000 });
++ const until = async (expr) => { const end = Date.now() + 5000; while (Date.now() < end) { if (await page.evaluate(expr)) return true; await wait(60); } return false; };
++ // Signed in, with the Dock: five places in one row.
++ await page.evaluate(OPEN(true));
++ assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places`);
++ await wait(350);
++ const row = await page.evaluate(ROW);
++ assert.deepEqual(row.map((t) => t.label + (t.chosen ? '*' : '') + (t.door ? `>${t.door}` : '')), ['Chat*', 'Feed', 'Messages>chats', 'AI>vibe', 'You'], `${name}: this site, Feed, Messages, AI, You, in that order`);
++ assert.ok(row.every((t) => t.top === row[0].top), `${name}: one row (${row.map((t) => t.top).join(',')})`);
++ assert.ok(row.every((t) => Math.abs(t.w - row[0].w) <= 1 && t.h >= 42), `${name}: the same width each, and tall enough to press (${row.map((t) => `${t.w}x${t.h}`).join(' ')})`);
++ assert.ok(row.every((t) => t.role === 'tab') && row[row.length - 1].right <= width, `${name}: all on the screen`);
++ assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`);
++ assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`);
++ // AI opens the Dock on Vibe; Messages on the conversations; neither becomes the chosen tab.
++ await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);
++ assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI opens the Dock's Vibe`);
++ await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`);
++ assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`);
++ assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`);
++ // Feed is a tab of this sheet.
++ await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`);
++ assert.ok(await until(`(document.querySelector('.ovs-starttabs [aria-selected="true"]') || {}).textContent.trim().startsWith('Feed')`), `${name}: Feed is chosen in place`);
++ await page.evaluate('OpenVibeSearch.close()'); await wait(300);
++ // A visitor: three places, as before.
++ await page.evaluate(OPEN(false));
++ assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 3`), `${name}: a visitor has this site, Feed and You`);
++ assert.deepEqual((await page.evaluate(ROW)).map((t) => t.label), ['Chat', 'Feed', 'You'].map((x, i) => x), `${name}: in that order`);
++ await page.close();
++ }
++ console.log('menu-places-chrome: one row of five places signed in (this site, Feed, Messages, AI, You), no second row, the doors open the Dock, three places for a visitor');
++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
++})().catch((err) => { console.error(err); process.exit(1); });