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)
Changes
@@@ -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.@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.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)",@@@ -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'];@@@ -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 forevery 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();@@@ -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));@@@ -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; } }@@@ -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 itconst 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 };@@@ -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",@@@ -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": {@@@ -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>`;@@@ -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 ──────────────────────────────────────────────nnew file mode 100644@@@ -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); });
@@ -1,5 +1,29 @@
@@ -1,5 +1,29 @@
# Changelog
# 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
## 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.
**The bar's right end, one tooltip, and one frame for mail.** From the owner's notes of 2026-11-10.
@@ -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.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.193.0",
+ "version": "4.194.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)",
@@ -52,7 +52,8 @@
@@ -52,7 +52,8 @@
clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',
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.
// 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
// 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.
// newer one stays on this site's copy until the Network learns it.
const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];
const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];
@@ -89,7 +89,6 @@
@@ -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 .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 { position: relative; }
.openvibe-navbar .openvibe-navbar-brand .flame::after { content: ''; position: absolute; inset: -8px -4px -8px -12px; }
.openvibe-navbar .openvibe-navbar-brand .flame::after { content: ''; position: absolute; inset: -8px -4px -8px -12px; }
- /* 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; }
.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. */
/* 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; }
.openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -5px -8px -5px -3px; }
@@ -354,18 +353,21 @@
@@ -354,18 +353,21 @@
}
}
.openvibe-navbar-avatar:hover { border-color: var(--accent, #3b82f6); }
.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 { 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: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); }
.openvibe-navbar-login:active { transform: scale(.9); }
.ovsi { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; overflow: visible; }
.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; }
.ovsi-o { opacity: 0; stroke-dasharray: 30 39; }
.is-going .ovsi-o { opacity: 1; animation: ovsi-go 1s linear infinite; }
.is-going .ovsi-o { opacity: 1; animation: ovsi-go 1s linear infinite; }
.is-going .ovsi-p { opacity: .55; }
.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; } }
@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
/* 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
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 @@
@@ -563,7 +565,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': '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
// 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">.
@@ -2116,7 +2118,7 @@
@@ -2116,7 +2118,7 @@
<div id="openvibe-bell-mount"></div>
<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>` : ''}
${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') :
${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>
</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>` : ''}
${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 @@
@@ -2124,7 +2126,9 @@
bindLinks(nav, links);
bindLinks(nav, links);
hookSearch(nav);
hookSearch(nav);
hookSwitcher(nav); hookPages(nav); hookSignIn(nav); hookFit(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);
bindChips(nav);
bindChipsAll(nav);
bindChipsAll(nav);
queueIcons();
queueIcons();
@@ -92,4 +92,6 @@ html[data-ov-motion=off] .ovtip { transform: none; transition: none; }
@@ -92,4 +92,6 @@ html[data-ov-motion=off] .ovtip { transform: none; transition: none; }
root.addEventListener('resize', hide, { passive: true });
root.addEventListener('resize', hide, { passive: true });
root.OVTip = { show, hide };
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));
}(typeof window !== 'undefined' ? window : globalThis));
@@ -34,3 +34,23 @@
@@ -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-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}}
.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}
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; } }
@@ -23,6 +23,14 @@
@@ -23,6 +23,14 @@
}
}
return hosts.has(url.hostname.toLowerCase());
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) {
async function open(options) {
close();
close();
const token = (options.getToken ? options.getToken() : options.token) || null;
const token = (options.getToken ? options.getToken() : options.token) || null;
@@ -40,13 +48,15 @@
@@ -40,13 +48,15 @@
heading.lastChild.id = 'ov-wallet-title';
heading.lastChild.id = 'ov-wallet-title';
const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
header.append(heading, exit);
header.append(heading, exit);
- const body = el('div', 'ov-wallet-body'), 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);
status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body);
const previousFocus = document.activeElement, previousOverflow = document.documentElement.style.overflow;
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
// 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.
// make its saved state "hidden". Only a page without it needs the wallet's own lock.
const ownLock = !root.OVSurfaces;
const ownLock = !root.OVSurfaces;
let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [];
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 shown = {}; // what each balance last showed, so a change counts from it
const pending = new Map();
const pending = new Map();
const session = { close() {
const session = { close() {
@@ -149,6 +159,8 @@
@@ -149,6 +159,8 @@
});
});
body.prepend(form); form.scrollIntoView({ block: 'start' }); input.focus();
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() {
function render() {
body.replaceChildren(status); status.textContent = data.money_writes_frozen ? 'Purchases and top-ups are paused. Your balances and items are still here.' : '';
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');
status.classList.remove('is-error');
@@ -178,7 +190,8 @@
@@ -178,7 +190,8 @@
}
}
const store = section('Store', 'A little more you. Items travel with your OpenVibe account.');
const store = section('Store', 'A little more you. Items travel with your OpenVibe account.');
const grid = el('div', 'ov-wallet-grid'); store.append(grid);
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) {
for (const item of storeItems) {
const card = el('article', 'ov-wallet-item'); card.append(preview(item.preview, item.name), el('h4', '', item.name), el('p', 'ov-wallet-muted', `By ${maker(item.issuer)}`));
const card = el('article', 'ov-wallet-item'); card.append(preview(item.preview, item.name), el('h4', '', item.name), el('p', 'ov-wallet-muted', `By ${maker(item.issuer)}`));
const owned = item.owned || (allItems && allItems.instances.some((i) => i.definition_id === item.id && i.state === 'owned'));
const owned = item.owned || (allItems && allItems.instances.some((i) => i.definition_id === item.id && i.state === 'owned'));
@@ -196,7 +209,8 @@
@@ -196,7 +209,8 @@
}
}
const mine = section('Your items', 'Wear them wherever their kind is supported.');
const mine = section('Your items', 'Wear them wherever their kind is supported.');
const owned = allItems ? allItems.instances.filter((i) => i.state === 'owned') : [];
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) {
for (const item of owned) {
const def = allItems.definitions[item.definition_id]; if (!def) continue;
const def = allItems.definitions[item.definition_id]; if (!def) continue;
const kind = kinds.find((k) => k.id === def.kind), row = el('article', 'ov-wallet-owned-row'), copy = el('div');
const kind = kinds.find((k) => k.id === def.kind), row = el('article', 'ov-wallet-owned-row'), copy = el('div');
@@ -237,13 +251,24 @@
@@ -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(); });
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.addEventListener('openvibe-auth-changed', session.close);
document.body.append(dialog); dialog.showModal(); if (ownLock) document.documentElement.style.overflow = 'hidden'; exit.focus();
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 {
try {
await refreshWallet();
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 : [];
identity(); storeItems = results[0].status === 'fulfilled' ? results[0].value.items : [];
kinds = results[2].status === 'fulfilled' ? results[2].value.kinds : [];
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.'));
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;
return session;
}
}
const api = { open, close, amount, checkoutAllowed };
const api = { open, close, amount, checkoutAllowed };
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.193.0",
+ "version": "4.194.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.193.0",
+ "version": "4.194.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.193.0",
+ "version": "4.194.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": {
@@ -214,7 +214,10 @@
@@ -214,7 +214,10 @@
.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; flex-wrap: wrap; 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. */
+/* 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 { 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;
.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; }
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 @@
@@ -1011,9 +1014,24 @@
const me = ctx.token && ctx.user && !ctx.user.is_anon ? ctx.user : null;
const me = ctx.token && ctx.user && !ctx.user.is_anon ? ctx.user : null;
let siteId = ctx.service || '';
let siteId = ctx.service || '';
try { const b0 = basis(); if (b0.mineSite) siteId = b0.mineSite.icon || b0.mineSite.id; } catch { /* the sites load later */ }
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');
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');
b.setAttribute('aria-selected', tab === id ? 'true' : 'false');
const ic = document.createElement('span'); ic.className = 'ovs-tab-ic';
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); }
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 @@
@@ -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); }
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 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>`;
@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// 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
// 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.
// 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'];
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 ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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); });