openvibers/openvibe.shared Commits
4.187.0: the menu button and the mark the owner chose, the person last in the bar, one menu (#409)
Changes
@@@ -1,5 +1,32 @@# Changelog++## 4.187.0 — 2026-11-10++++**The bar the owner chose.** 2026-11-10, in the Arena: "here's the new menu button and the logo … these two, but++improve upon and expand on it. And let's move the user … so that it's to the right of this. And instead of having++messaging and notifications, just have the notifications."++++- **The menu button is "Search and apps"** (the Arena's M57): the menu itself, small: a search bar over three apps.++ Under the pointer its parts lift in turn; while the menu loads the apps breathe; open, the bar turns into one stroke++ of the close mark and the three apps line up as the other. One drawing (`OpenVibeOrb.svg()`, navbar.js) for the++ bar, the menu's sheet and the Dock's sheet, where three copies of nine dots were.++- **The mark has the wide gap** (the Arena's P07): the ground around the V where it crosses the O is 14, was 7, in++ `ov-mark.js`, `mark.js`, the app icons and the loader's tab icon alike (`test/mark.test.js`).++- **The person is last in the bar**: balances, the bell, the menu button, then the picture (or Sign In).++- **Messages is the bell.** The Messages button leaves the bar; the count of new messages (Chat's, or a site's own++ through `setMessages(count)`) is added to the bell's number and named in its label ("Notifications, 1 unread, 2 new++ messages"). With new messages and nothing else new, the bell opens the Dock on Chats; otherwise on Activity.++- **One menu.** The owner: "make sure these two share the same universal system … it just opens the actual first++ tab … same UI." The mark opens the OpenVibe menu on its first tab; `site-switcher.js`, its panel, its own icons++ and its "here" label are deleted. `OpenVibeNavbar.openSwitcher()` opens the menu.++- **All apps is a small button beside Edit** in the menu's You tab ("All 65"), where a bar the width of the sheet++ was under the grid.++- **An agent's turn shows it is thinking**: in the conversation kit (`thread.css`), the agent's avatar shows three++ rising dots while its turn is open.++++Removed: `site-switcher.js` (and its export), the Messages button and its `messages: { href, onOpen, label }`++behaviours (the Dock's Chats tab is where messages open; `messages: false` still turns the count off).++## 4.186.0 — 2026-11-10**The mark at work is a ring of dots, and thinking is a state of its own.** The owner, 2026-11-10: "the progress@@@ -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.186.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.187.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.@@@ -827,11 +827,13 @@ Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,bots and unavailable sessionStorage suppress the attempt. The site's loginhandler must return silent failures without showing a login form (openvibe-sdk/sso).OpenVibeSSO.fedcm remains available for browser-native sign-in flows.--Site switcher (4.48): the OpenVibe mark is a button that opens-- site-switcher.js, a compact grid of every OpenVibe site with Recent, Pinned,-- then the frame's groups. Recent and pinned follow the account (Network-- /api/themes/me/sites) when signed in. The site's name stays the link home;-- switcher: false keeps the mark as a home link.++One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menu++ on its first tab, as the menu button does (there is no separate site++ switcher). The site's name stays the link home; switcher: false keeps the++ mark as a home link.++The bar's order (4.187): balances, the bell, the menu button, then the++ person's picture (or Sign In), last. New messages are counted on the bell++ (setMessages(count)); there is no Messages button.Balances: chips: [{ id, icon, value, label, tone, title, onClick, hidden }]are status chips beside the bell (Live's OpenCoins and Vibes); setChip(id,value) updates one. Two or more fold into one chip at 560 px and narrower.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.186.0",++ "version": "4.187.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -34,7 +34,8 @@const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;// Close is the OpenVibe menu's orb in its × form (11-09, the owner: "replace this close button with our own… the// corner button… its close form… nice animations"): nine dots that fold into an × as the Dock opens.-- const ORBX = `<svg viewBox="0 0 24 24" aria-hidden="true"><g class="ovdk-ox">${[[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]].map(([x, y, r]) => `<circle class="ovdk-o${r}" cx="${x}" cy="${y}" r="${r ? 2.15 : 2.5}"/>`).join('')}</g></svg>`;++ // The close button is the menu button, already crossed (navbar.js, root.OpenVibeOrb): it forms as the sheet opens.++ const ORBX = () => (root.OpenVibeOrb ? root.OpenVibeOrb.svg(22, true) : '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>');const G = {spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',@@@ -182,17 +183,11 @@.ovdk-acts button:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }.ovdk-acts button svg { width: 19px; height: 19px; display: block; }.ovdk-orbx svg { width: 22px !important; height: 22px !important; overflow: visible; }-- .ovdk-ox { transform-origin: 12px 12px; transform: rotate(180deg) scale(.86); transition: transform .45s cubic-bezier(.2, 1.35, .3, 1); }-- .ovdk-ox circle { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: opacity .25s, transform .3s cubic-bezier(.2, 1.4, .3, 1), fill .2s; }-- .ovdk-ox .ovdk-o1 { opacity: 0; transform: scale(0); }-- .ovdk-ox .ovdk-o0 { fill: var(--accent, #3b82f6); }-- .ovdk.is-in .ovdk-ox { animation: ovdk-ox-in .55s cubic-bezier(.2, 1.35, .3, 1) both; }-- .ovdk.is-in .ovdk-ox .ovdk-o1 { animation: ovdk-o1-out .4s ease both; }-- .ovdk-orbx:hover .ovdk-ox, .ovdk-orbx:focus-visible .ovdk-ox { transform: rotate(270deg) scale(.9); }-- .ovdk-orbx:hover .ovdk-ox circle { fill: var(--accent-light, #60a5fa); }-- .ovdk-orbx:active .ovdk-ox { transform: rotate(270deg) scale(.7); }-- @keyframes ovdk-ox-in { from { transform: rotate(45deg) scale(.8); } }-- @keyframes ovdk-o1-out { from { opacity: 1; transform: scale(1); } }++ .ovdk-orbx { --ovo-a: var(--accent-light, var(--accent, #60a5fa)); }++ .ovdk.is-in .ovdk-orbx .ovmb :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }++ .ovdk-orbx:hover .ovmb, .ovdk-orbx:focus-visible .ovmb { transform: rotate(90deg); }++ .ovdk-orbx .ovmb { transition: transform .35s cubic-bezier(.2,1.3,.3,1); }++ .ovdk-orbx:active .ovmb { transform: rotate(90deg) scale(.8); }/* A phone's sheet opens out of the navbar's orb as the menu's does (4.186.0; see CHANGELOG): one move, one close button. */.ovdk.from-orb.is-in { animation: ovdk-reveal .38s cubic-bezier(.2,.9,.25,1) both; }.ovdk.from-orb.is-out { animation: ovdk-fold .28s cubic-bezier(.5,0,.75,.2) both; pointer-events: none; }@@@ -204,23 +199,11 @@.from-orb .ovdk-acts .ovdk-orbx.is-from { background: transparent; box-shadow: none; }.from-orb.is-in .ovdk-orbx::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--accent-light, #60a5fa); pointer-events: none; animation: ovdk-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }@keyframes ovdk-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }-- .from-orb.is-in .ovdk-ox, .from-orb.is-in .ovdk-o1 { animation: none !important; }.from-orb .ovdk-orbx { color: var(--text-primary, #ececf1); }.from-orb .ovdk-orbx svg { transition: transform .42s cubic-bezier(.2, 1.1, .3, 1); }-- .from-orb .ovdk-ox circle { transition: fill .3s, opacity .3s, transform .35s cubic-bezier(.2, 1.4, .3, 1); }-- .from-orb .ovdk-ox { transform: rotate(180deg) scale(.95); }-- .from-orb .ovdk-ox :is(.ovdk-o0, .ovdk-o2) { fill: var(--accent-light, #60a5fa); transform: scale(1.12); }-- .from-orb .is-from .ovdk-ox .ovdk-o0 { fill: var(--accent-light, #60a5fa); }.ovdk-orbx.is-set, .ovdk-orbx.is-set * { transition: none !important; }.from-orb .is-from svg { transform: translate(var(--orb-dx, 0), var(--orb-dy, 0)) scale(var(--orb-s, 1)); }-- .from-orb .is-from .ovdk-ox { transform: rotate(45deg) scale(.8); }-- .from-orb .is-from .ovdk-ox circle { fill: var(--text-secondary, #a8b3c4); opacity: 1; transform: none; }-- .from-orb .is-from .ovdk-ox .ovdk-o2 { opacity: .6; }.ovnav-orb.is-dock { transform: translate(var(--dock-dx, 0), var(--dock-dy, 0)) scale(var(--dock-s, 1)) !important; color: var(--text-primary, #e6edf7) !important; }-- .ovnav-orb.is-dock .ovo-g { transform: rotate(180deg) scale(.86); transition-duration: .45s; }-- .ovnav-orb.is-dock .ovo-d { animation: none; }-- .ovnav-orb.is-dock .ovo-r1 { opacity: 0; transform: scale(0); }-- .ovnav-orb.is-dock :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; }.ovdk-more { position: absolute; z-index: 6; right: 8px; top: calc(100% - 4px); width: min(320px, calc(100% - 16px)); display: grid; gap: 2px; padding: 6px; border-radius: 16px;border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); box-shadow: 0 18px 40px -12px rgba(0,0,0,.55); cursor: default; }.ovdk-more[hidden] { display: none; }@@@ -366,7 +349,7 @@<div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div><div class="ovdk-acts"><button type="button" data-act="more" aria-haspopup="true" aria-expanded="false" title="More" aria-label="More: a timer, the Dock's button">${svg(G.more)}</button>-- <button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX}</button>++ <button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX()}</button></div><div class="ovdk-more" role="menu" aria-label="More" hidden><button type="button" role="menuitem" data-act="timer">${svg(G.clock)}<span><b>Start a timer</b><small>A countdown or a focus round, on the Dock's button</small></span></button>@@@ -35,7 +35,6 @@ const BROWSER = Object.freeze(['dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel'account-switcher.js',-- 'site-switcher.js', // the OpenVibe mark's site switcher (4.48.0); navbar.js loads it on the first tap. Missing from this list until 4.106.1, so every site answered 404 for it and the mark did nothing'user-card.js','ov-mark.js', // the OV brand mark drop-in'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)@@@ -30,6 +30,9 @@ const COLORS = Object.freeze({ o: '#4f7cff', v: '#8b5cf6' });const f = (v) => +v.toFixed(2);/** The Pair: ring radius r and stroke sw, the V's stroke vsw; the V's left cap a, tip t and right cap b, as multiples of the ring's outer radius from its centre. */++// The ground around the V where it crosses the O: the wide gap (the owner's pick, 2026-11-10; it was 7). ov-mark.js++// has the same number (test/mark.test.js).++const HALO = 14;function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;const A = [f(cx + a * R), f(cy - R + vsw / 2)], T = [f(cx + t * R), f(cy + R - vsw / 2)], B = [f(cx + b * R), f(cy - R + vsw / 2)];@@@ -38,7 +41,7 @@ function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {const x0 = f(cx - R), x1 = f(B[0] + vsw / 2), pad = 2;const w = f(x1 - x0 + 2 * pad);return {-- cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + 7, A, T, B, ring,++ cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + HALO, A, T, B, ring,// A square box centred on the mark, and a wide one that hugs it.box: `${f(50 - w / 2)} ${f(50 - w / 2)} ${w} ${w}`,wideBox: `${f(50 - w / 2)} ${f(50 - R - pad)} ${w} ${f(2 * R + 2 * pad)}`,@@@ -289,33 +289,44 @@.openvibe-navbar-spacer { flex: 1; }.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }-- /* The OpenVibe menu's button (4.25.0; the owner, 2026-10-28: "the little diamond thing… an icon that has cool-- animations and effects… next to the logo"): nine dots set as a diamond beside the brand. Hovered, a ripple-- runs out from the centre and the dots take the accent's colours; while the menu loads they wave; while it is-- open the diamond turns into an ×. Transform, opacity and fill only, and nothing moves while it rests. */++ /* The person (or Sign In) is last, after the menu button, with a little air between them. */++ .openvibe-navbar .ovnav-burger + :is(.openvibe-navbar-avatar, .openvibe-navbar-login) { margin-left: 3px; }++ /* The OpenVibe menu's button (4.187.0; the owner's pick of 2026-11-10: a search bar over three apps). Under++ the pointer its parts lift in turn; while the menu loads the apps breathe; open, the bar and the apps cross++ into the close mark. Transform, opacity and fill only, and nothing moves while it rests. */.openvibe-navbar .ovnav-orb { all: unset; box-sizing: border-box; position: relative; display: inline-grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 11px; cursor: pointer; color: var(--text-secondary, #a8b3c4); --ovo-a: var(--accent-light, var(--accent, #60a5fa)); --ovo-b: color-mix(in oklab, var(--accent, #3b82f6) 45%, #e879f9); }.openvibe-navbar .ovnav-orb::before { content: ""; position: absolute; inset: -3px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--accent, #3b82f6) 38%, transparent), transparent); opacity: 0; transform: scale(.5); transition: opacity .25s, transform .4s cubic-bezier(.2,1.4,.3,1); pointer-events: none; }.openvibe-navbar .ovnav-orb:hover::before, .openvibe-navbar .ovnav-orb:focus-visible::before, .openvibe-navbar .ovnav-orb[aria-expanded="true"]::before { opacity: 1; transform: none; }.openvibe-navbar .ovnav-orb svg { position: relative; display: block; overflow: visible; }-- .ovnav-orb .ovo-g { transform-origin: 12px 12px; transform: rotate(45deg) scale(.8); transition: transform .55s cubic-bezier(.2,1.35,.3,1); }-- .ovnav-orb .ovo-d { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: fill .3s, opacity .3s, transform .35s cubic-bezier(.2,1.4,.3,1); }-- .ovnav-orb .ovo-r2 { opacity: .6; }-- .ovnav-orb .ovo-r0 { fill: var(--ovo-a); }-- .ovnav-orb:is(:hover, :focus-visible) .ovo-r1 { fill: var(--ovo-a); }-- .ovnav-orb:is(:hover, :focus-visible) .ovo-r2 { fill: var(--ovo-b); opacity: 1; }-- .ovnav-orb:is(:hover, :focus-visible) .ovo-r0 { fill: var(--text-primary, #e6edf7); }-- .ovnav-orb:is(:hover, .ovo-intro) .ovo-d { animation: ovo-ripple .7s cubic-bezier(.3,.7,.3,1) both; }-- .ovnav-orb.is-busy .ovo-d { animation: ovo-wave .9s ease-in-out infinite; }-- .ovnav-orb .ovo-d.ovo-r1 { animation-delay: .07s; } .ovnav-orb .ovo-d.ovo-r2 { animation-delay: .14s; }++ .ovmb { display: block; overflow: visible; }++ .ovmb-bar { fill: transparent; stroke: currentColor; stroke-width: 1.9; }++ .ovmb-app { fill: currentColor; }++ .ovmb-dot, .ovmb-a1 { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }++ .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { transform-box: fill-box; transform-origin: center; transition: transform .5s cubic-bezier(.2,1.3,.3,1), fill .3s, stroke-width .3s, opacity .25s; }++ .ovmb-a0 { transition-delay: .03s; } .ovmb-a1 { transition-delay: .06s; } .ovmb-a2 { transition-delay: .09s; }++ /* Under the pointer (and once, the first time it is seen): the bar and the three apps lift in turn. */++ :is(.ovnav-orb, .ovs-orb):is(:hover, .ovo-intro) .ovmb:not(.is-x) :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: ovmb-wave .7s ease-in-out both; }++ .ovmb .ovmb-a0 { animation-delay: .08s; } .ovmb .ovmb-a1 { animation-delay: .15s; } .ovmb .ovmb-a2 { animation-delay: .22s; }++ :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }++ :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-a1 { fill: var(--ovo-b, #e879f9); }++ /* While the menu loads: the apps breathe in turn. */++ .ovnav-orb.is-busy .ovmb-app { animation: ovmb-busy .9s ease-in-out infinite; }++ /* Open: the bar is one stroke of the close mark, the three apps the other. */++ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app), .ovmb.is-x :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: none; }++ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-bar, .ovmb.is-x .ovmb-bar { transform: translateY(4px) rotate(45deg) scale(.9, .43); fill: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }++ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-dot, .ovmb.is-x .ovmb-dot { transform: translate(4.6px, 4px) scale(0); opacity: 0; }++ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-app, .ovmb.is-x .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }++ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a0, .ovmb.is-x .ovmb-a0 { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.3, .6); }++ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a1, .ovmb.is-x .ovmb-a1 { transform: translate(0, -4.6px) rotate(-45deg) scale(1.3, .6); }++ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a2, .ovmb.is-x .ovmb-a2 { transform: translate(-2.4px, -8px) rotate(-45deg) scale(1.3, .6); }++ /* A close mark that arrives already made (the Dock's sheet) forms from the button it came from. */++ .ovmb.is-forming :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }++ @keyframes ovmb-form { from { transform: none; } }++ @keyframes ovmb-wave { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(calc(-2.6px * var(--ov-move, 1))); } }++ @keyframes ovmb-busy { 0%, 100% { transform: scale(.72); opacity: .5; } 50% { transform: scale(1.18); opacity: 1; } }.openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); }-- .ovnav-orb[aria-expanded="true"] .ovo-g { transform: rotate(180deg) scale(.86); }-- .ovnav-orb[aria-expanded="true"] .ovo-d { animation: none; }-- .openvibe-navbar :is(.ovnav-orb, .ovnav-msgs, .openvibe-bell, .openvibe-navbar-avatar) { transition: transform .18s cubic-bezier(.2, 1.4, .3, 1), background-color .15s, color .15s; }-- .openvibe-navbar :is(.ovnav-orb, .ovnav-msgs, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); }-- .ovnav-orb[aria-expanded="true"] .ovo-r1 { opacity: 0; transform: scale(0); }-- .ovnav-orb[aria-expanded="true"] :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; }-- @keyframes ovo-ripple { 0%, 100% { transform: scale(1); } 45% { transform: scale(1.55); } }-- @keyframes ovo-wave { 0%, 100% { transform: scale(.7); opacity: .5; } 50% { transform: scale(1.25); opacity: 1; } }++ .openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar) { transition: transform .18s cubic-bezier(.2, 1.4, .3, 1), background-color .15s, color .15s; }++ .openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); }.openvibe-navbar .openvibe-navbar-brand .ovnav-orb { margin-left: 1px; }/* 4.148.0 (the owner: "replace the hamburger with that button"): the phone's menu button is the orb, in theburger's place at the bar's right. Hidden on a wide bar (its links are there), shown at 860 px and below;@@@ -329,18 +340,12 @@/* Messages (4.25.0; the owner, 2026-10-28: the floating DM button was "buggy as hell on mobile… app style"): asite that has messages puts them in the bar beside the bell, with the unread count, instead of a bubbleover the page. */-- .openvibe-navbar .ovnav-msgs { all: unset; box-sizing: border-box; position: relative; display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 11px; cursor: pointer; color: var(--text-secondary, #a8b3c4); }-- .openvibe-navbar .ovnav-msgs:hover, .openvibe-navbar .ovnav-msgs:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--text-primary, #e6edf7); }-- .openvibe-navbar .ovnav-msgs .ovnav-ic { width: 22px; height: 22px; }.ovnav-ovg { width: 1.15em; height: 1.15em; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; overflow: visible; vertical-align: -.2em; }.ovnav-chip .ovnav-ovg, .ovnav-chip-mini .ovnav-ovg, .ovnav-chips-pop .ovnav-ovg { stroke-width: 2.3; }.ovnav-ovg > .ovg-s { display: none; fill: currentColor; stroke: none; }.ovnav-ovg > .ovg-s .ln { fill: none; stroke: currentColor; stroke-width: 2.6px; }html[data-ov-icons=solid] .ovnav-ovg.has-s, .ovnav-ab.is-here .ovnav-ovg.has-s { stroke-width: 0; }html[data-ov-icons=solid] .ovnav-ovg.has-s > .ovg-s, .ovnav-ab.is-here .ovnav-ovg.has-s > .ovg-s { display: inline; }-- .openvibe-navbar .ovnav-msgs b { position: absolute; top: -1px; right: -3px; min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--live-red, #ef4444); color: #fff; font: 700 10px/16px Inter, system-ui, sans-serif; text-align: center; box-shadow: 0 0 0 2px var(--bg-secondary, #111826); }-- .openvibe-navbar .ovnav-msgs b:empty { display: none; }-- @media (max-width: 400px) { .openvibe-navbar .ovnav-msgs { width: 32px; height: 32px; } }.openvibe-navbar-avatar {width: 32px; height: 32px; border-radius: 50%; cursor: pointer;@@@ -363,12 +368,12 @@Sign In beside it; Sign In's corners follow the orb's. */.openvibe-navbar .ovnav-orb { width: 36px; height: 36px; }.openvibe-navbar .ovnav-orb svg { width: 26px; height: 26px; }-- .openvibe-navbar .ovnav-msgs, .openvibe-navbar .ovnav-featured-phone { width: 36px; height: 36px; }++ .openvibe-navbar .ovnav-featured-phone { width: 36px; height: 36px; }.openvibe-navbar .openvibe-navbar-avatar { width: 36px; height: 36px; box-sizing: border-box; }.openvibe-navbar .ovnav-chip { height: 36px; }.openvibe-navbar .openvibe-navbar-login { box-sizing: border-box; height: 36px; padding: 0 16px; font-size: 14px; border-radius: 10px; }@media (max-width: 400px) {-- .openvibe-navbar .ovnav-orb, .openvibe-navbar .ovnav-msgs, .openvibe-navbar .ovnav-featured-phone, .openvibe-navbar .openvibe-navbar-avatar { width: 34px; height: 34px; }++ .openvibe-navbar .ovnav-orb, .openvibe-navbar .ovnav-featured-phone, .openvibe-navbar .openvibe-navbar-avatar { width: 34px; height: 34px; }.openvibe-navbar .ovnav-orb svg { width: 24px; height: 24px; }.openvibe-navbar .ovnav-chip, .openvibe-navbar .openvibe-navbar-login { height: 34px; }.openvibe-navbar .openvibe-navbar-login { padding: 0 13px; }@@@ -557,17 +562,16 @@'fa-circle-half-stroke': [0, '<circle cx="12" cy="12" r="9"/><path d="M12 3v18M12 7.5h7M12 12h9M12 16.5h7"/>', '<path fill-rule="evenodd" d="M12 2.4a9.6 9.6 0 1 0 0 19.2 9.6 9.6 0 0 0 0-19.2Zm0 2.2a7.4 7.4 0 0 1 0 14.8Z"/>'],'fa-moon': [0, '<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z"/>', '<path d="M20.4 14.4A8.5 8.5 0 1 1 9.6 3.6a7 7 0 0 0 10.8 10.8Z"/>'],'fa-sun': [0, '<circle cx="12" cy="12" r="3.8"/><path d="M12 2.5v2M12 19.6v2M5.25 5.25l1.4 1.4M17.4 17.4l1.4 1.4M2.5 12h2M19.6 12h2M5.25 18.75l1.4 -1.4M17.4 6.6l1.4 -1.4"/>', '<circle cx="12" cy="12" r="4.6"/><path class="ln" d="M12 2.2v1.6M12 20.2v1.6M5 5l1.1 1.1M17.9 17.9 19 19M2.2 12h1.6M20.2 12h1.6M5 19l1.1-1.1M17.9 6.1 19 5"/>'],-- 'fa-comment-dots': [0, '<path d="M4 5h16v11H8l-4 4Z"/>', '<path d="M3.5 5.6A1.6 1.6 0 0 1 5.1 4h13.8a1.6 1.6 0 0 1 1.6 1.6v9.8a1.6 1.6 0 0 1-1.6 1.6H8.6L3.5 21Z"/>'],'fa-brain': [0, '<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1.2V5.5A2 2 0 0 0 9 4ZM15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1.2"/>', '<path d="M9 3.8a3.1 3.1 0 0 0-3.1 3 3 3 0 0 0-2.1 5.2 3 3 0 0 0 2.1 5.1 3.1 3.1 0 0 0 5.5 1.3V5.5A2.2 2.2 0 0 0 9 3.8Z"/><path d="M15 3.8a3.1 3.1 0 0 1 3.1 3 3 3 0 0 1 2.1 5.2 3 3 0 0 1-2.1 5.1 3.1 3.1 0 0 1-5.5 1.3V5.5A2.2 2.2 0 0 1 15 3.8Z"/>'],};// Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives); 0 is an OpenVibe glyph.const NAV_ICONS_LAZY = {-- 0: 'fa-book fa-camera fa-comments fa-gamepad fa-gauge-high fa-hand-fist fa-house fa-link fa-list-check fa-network-wired fa-paste fa-people-arrows fa-scissors fa-screwdriver-wrench fa-shirt fa-text-height fa-user fa-video fa-wand-magic-sparkles fa-arrow-up-right-from-square fa-chevron-right fa-bars fa-xmark fa-magnifying-glass fa-file-pdf fa-arrow-down fa-arrow-right fa-clock fa-envelope fa-star fa-heart fa-play fa-circle-info fa-triangle-exclamation fa-check fa-globe fa-code fa-robot fa-photo-film fa-tower-cell fa-diagram-project fa-toolbox fa-money-bill-wave fa-crown fa-server fa-newspaper fa-tag fa-ticket fa-chart-line fa-pen-nib fa-key fa-plug fa-bolt fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music fa-file-lines fa-font fa-location-dot fa-lock fa-wave-square fa-map fa-utensils fa-user-shield fa-gauge fa-pen fa-earth-americas fa-hashtag fa-inbox fa-folder fa-puzzle-piece fa-shield fa-display fa-microphone fa-infinity fa-list fa-flag-checkered fa-tv fa-compass',++ 0: 'fa-book fa-camera fa-comments fa-gamepad fa-gauge-high fa-hand-fist fa-house fa-link fa-list-check fa-network-wired fa-paste fa-people-arrows fa-scissors fa-screwdriver-wrench fa-shirt fa-text-height fa-user fa-video fa-wand-magic-sparkles fa-arrow-up-right-from-square fa-chevron-right fa-bars fa-xmark fa-magnifying-glass fa-file-pdf fa-arrow-down fa-arrow-right fa-clock fa-envelope fa-star fa-heart fa-play fa-circle-info fa-triangle-exclamation fa-check fa-globe fa-code fa-robot fa-photo-film fa-tower-cell fa-diagram-project fa-toolbox fa-comment-dots fa-money-bill-wave fa-crown fa-server fa-newspaper fa-tag fa-ticket fa-chart-line fa-pen-nib fa-key fa-plug fa-bolt fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music fa-file-lines fa-font fa-location-dot fa-lock fa-wave-square fa-map fa-utensils fa-user-shield fa-gauge fa-pen fa-earth-americas fa-hashtag fa-inbox fa-folder fa-puzzle-piece fa-shield fa-display fa-microphone fa-infinity fa-list fa-flag-checkered fa-tv fa-compass',};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': '4fb7af77a431', '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': 'c6dbd7d46ae7', 'dock.js': '2c59b0f0e035', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'dc24929109b5', 'history.js': '1e6598f78ba4', 'site-switcher.js': 'd8980cf95157', 'appbar.js': '4e34b7d60372', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '6252acb43bd4', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };++ const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'bb502f67c4d5', 'dock.js': '00e20b4b2895', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': '1e6598f78ba4', 'appbar.js': '4e34b7d60372', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'db1d79b7b932', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };// 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">.@@@ -927,7 +931,7 @@return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}">${_config.switcher === false? `<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>`-- : `<button type="button" class="flame" id="openvibe-switch-btn" aria-haspopup="dialog" aria-expanded="false" aria-label="Switch to another OpenVibe site" title="Every OpenVibe site">${mark}</button>`}++ : `<button type="button" class="flame" id="openvibe-switch-btn" aria-haspopup="dialog" aria-expanded="false" aria-label="OpenVibe menu: every site and tool" title="Every OpenVibe site">${mark}</button>`}<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>${_config.search === false ? '' : orbHTML()}</div>`;@@@ -981,13 +985,16 @@try { const sc = document.createElement('script'); sc.id = 'ov-slots-loader'; sc.src = siblingV('nav-slots.js'); sc.async = true; sc.onload = () => paintSlots(); document.head.appendChild(sc); } catch { /* */ }}-- // The OpenVibe menu's button: nine dots as a diamond (the old launcher's, which the owner liked), the centre in the-- // accent. Rings: 0 the centre, 1 the edges, 2 the corners (the ripple's order; corners and centre make the ×).-- const ORB_DOTS = [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]];-- function orbSVG() {-- const dots = ORB_DOTS.map(([x, y, r]) => `<circle class="ovo-d ovo-r${r}" cx="${x}" cy="${y}" r="${r ? 2.15 : 2.5}"/>`).join('');-- return `<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><g class="ovo-g">${dots}</g></svg>`;++ // The OpenVibe menu's button (the owner's pick, 2026-11-10: the Arena's M57, "Search and apps"): the menu itself,++ // small: a search bar over three apps. Open, the bar turns into one stroke of the close mark and the three apps++ // line up as the other, so the button that opens the menu is, in the same place, the one that closes it. One++ // drawing for the bar, the menu's sheet and the Dock's (root.OpenVibeOrb); its styles are below (.ovmb).++ const ORB_PARTS = '<rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/>'++ + '<rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/>';++ function orbSVG(size = 22, open = false) {++ return `<svg class="ovmb${open ? ' is-x' : ''}" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${ORB_PARTS}</svg>`;}++ root.OpenVibeOrb = { svg: orbSVG };function orbHTML() {return `<button type="button" class="ovnav-search ovnav-orb" id="openvibe-search-btn" aria-label="OpenVibe menu: search, every site and tool (/)" title="Search and every OpenVibe site (/)" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>`;}@@@ -1064,34 +1071,10 @@}const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0];/** The chips folded into one (shown instead of them on a phone): each chip's icon and short value. */++ // New messages are counted on the bell (4.187.0; the owner, 2026-11-10: "instead of having messaging and++ // notifications, just have the notifications … it'll show you new messages as well"): there is no Messages button.let _msgCount = 0;-- const msgLabel = () => `Messages${_msgCount ? ` (${_msgCount} unread)` : ''}`;-- function messagesHTML() {-- const m = _config.messages || {};-- const href = typeof m.href === 'string' ? m.href : 'https://openvibe.chat/messages';-- const n = _msgCount ? (_msgCount > 99 ? '99+' : String(_msgCount)) : '';-- return `<a class="ovnav-msgs" id="openvibe-messages-btn" href="${escapeAttr(href)}" title="${escapeAttr(m.label || 'Messages')}" aria-label="${escapeAttr(msgLabel())}">${navIcon('fa-comment-dots')}<b>${n}</b></a>`;-- }-- function bindMessages(nav) {-- const a = nav.querySelector('#openvibe-messages-btn'); if (!a || typeof a.addEventListener !== 'function') return;-- a.addEventListener('click', (e) => {-- const m = _config.messages || {};-- if (e.metaKey || e.ctrlKey || e.shiftKey) return;-- // The default (4.130.0): the Dock's Chats tab; without the Dock, the link.-- if (m.__default && _config.dock !== false) {-- e.preventDefault();-- if (_config.token) openDock('chats');-- else refreshAuthState().then(() => {-- if (_config.token) openDock('chats');-- else root.location.href = a.href;-- });-- return;-- }-- if (typeof m.onOpen !== 'function') return;-- e.preventDefault(); try { m.onOpen(e); } catch { /* the link still works */ }-- });-- }-- // The default Messages button's count: Chat's GET /api/dm/unread, at most once a minute (README, "The navbar").++ // New messages, for the bell's count: Chat's GET /api/dm/unread, at most once a minute (README, "The navbar").let _unreadAt = 0;function loadUnread() {const m = _config.messages, u = _config.user;@@@ -1355,7 +1338,9 @@N.setBellAction(_dockOn ? () => {const D = root.OpenVibeDock; if (!D || typeof D.open !== 'function') return false;const st = typeof D._state === 'function' ? D._state() : {};-- if (D.isOpen() && st.tab === 'activity') D.close(); else D.open({ tab: 'activity' });++ // New messages and nothing else new: the bell opens on them. Otherwise on Activity, as before.++ const tab = _msgCount && N.unread && !N.unread() ? 'chats' : 'activity';++ if (D.isOpen() && st.tab === tab) D.close(); else D.open({ tab });return true;} : null);}@@@ -2128,18 +2113,16 @@<div class="openvibe-navbar-spacer"></div><div class="openvibe-navbar-right">${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}-- ${u && !isAnon && _config.messages ? messagesHTML() : ''}${featuredPhoneHTML(links)}<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" 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)}">Sign In</a>`}-- ${links.length ? `<button type="button" class="ovnav-burger ovnav-orb" id="openvibe-burger" aria-label="Menu: this site, search and every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>` : ''}</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>` : ''}`;if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));bindLinks(nav, links);-- bindMessages(nav);hookSearch(nav);hookSwitcher(nav);bindChips(nav);@@@ -2345,30 +2328,14 @@}const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open'));-- // ── The site switcher (site-switcher.js, 4.48.0): the OpenVibe mark opens a compact grid of every site ──-- // Recent and pinned follow the account (Network /api/themes/me/sites) when signed in; the name stays the home link.-- let _switcherLoading = false;++ // ── The mark opens the OpenVibe menu on its first tab (4.187.0; the owner, 2026-11-10: the mark's own panel and++ // the menu were two lists of the same sites, drawn differently and updated apart). One menu, two ways in. ──function openSwitcher() {-- const btn = _navEl && _navEl.querySelector('#openvibe-switch-btn');-- const anchor = () => {-- if (!_navEl || !btn || !btn.getBoundingClientRect) return null;-- const bar = _navEl.getBoundingClientRect(), at = btn.getBoundingClientRect();-- return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6, cx: at.left + at.width / 2, cy: at.top + at.height / 2 } : null;-- };-- const go = () => {-- const S = root.OpenVibeSiteSwitcher;-- if (!S) return;-- if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close();-- if (_navEl) _navEl.classList.remove('ovnav-hide');-- S.open({ service: _config.service, frame: OVFrame.get((d) => S.update(d)), token: _config.token, apiBase: 'https://openvibe.network', user: _config.user,-- icons: (scope) => loadOvIcons(scope), anchor: anchor(),-- onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-switch-btn'); if (b) b.setAttribute('aria-expanded', 'false'); } });-- const b = _navEl && _navEl.querySelector('#openvibe-switch-btn'); if (b) b.setAttribute('aria-expanded', 'true');-- };-- if (root.OpenVibeSiteSwitcher) return go();-- if (_switcherLoading) return;-- _switcherLoading = true;-- try { const sc = document.createElement('script'); sc.src = siblingV('site-switcher.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _switcherLoading = false; }; document.head.appendChild(sc); } catch { _switcherLoading = false; }++ if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }++ // As if the menu button itself were pressed (the one on screen: beside the name on a wide bar, at the bar's end++ // on a phone), so the sheet opens out of it and it becomes the close button, whichever way in was used.++ const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];++ openSearch('', 'sites', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null);}// A signed-in person's visit moves this site to the front of their Recent, at most once in 30 minutes, and never// under Global Privacy Control or Do Not Track (as the page-view beacon).@@@ -2384,9 +2351,8 @@function hookSwitcher(nav) {const b = nav.querySelector('#openvibe-switch-btn');if (!b) return;-- b.addEventListener('click', () => { if (root.OpenVibeSiteSwitcher && root.OpenVibeSiteSwitcher.isOpen()) root.OpenVibeSiteSwitcher.close(); else openSwitcher(); });++ b.addEventListener('click', () => openSwitcher());for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });-- if (root.OpenVibeSiteSwitcher && root.OpenVibeSiteSwitcher.isOpen()) b.setAttribute('aria-expanded', 'true');// Once the page is idle: the visit never competes with the first paint or the sign-in check.if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => recordVisit(), { timeout: 8000 });else setTimeout(recordVisit, 3000);@@@ -2612,12 +2578,12 @@/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */setAppBar(spec) { _config.appBar = spec; renderAppBar(); },-- /** Messages beside the bell: messages: { href?, onOpen?(), label? } in init (or here), and the unread count. */++ /** New messages: the count joins the bell's (there is no Messages button); a spec keeps `messages` on or off. */setMessages(count, spec) {-- if (spec !== undefined) { _config.messages = spec; if (_navEl) render(); }++ if (spec !== undefined) _config.messages = spec;_msgCount = Math.max(0, Number(count) || 0);-- const b = typeof document !== 'undefined' && document.querySelector('#openvibe-messages-btn b');-- if (b) { b.textContent = _msgCount ? (_msgCount > 99 ? '99+' : String(_msgCount)) : ''; b.parentElement.setAttribute('aria-label', msgLabel()); }++ const N = root.OpenVibeNotifications;++ if (N && typeof N.setMessages === 'function') N.setMessages(_msgCount);},/** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */@@@ -100,6 +100,7 @@let _pollTimer = null;let _lastSeenAt = null; // newest created_at we have toasted / seenlet _unreadCount = 0;++ let _msgs = 0; // new messages (navbar.js setMessages): on the bell's badge with the notifications, never in the list's own countlet _toastContainer = null;let _panelEl = null;let _bellEl = null;@@@ -840,18 +841,22 @@_bellEl = bell;return bell;}++ // The bell's own number: notifications and new messages together, each named in its label.++ function paintBell(ring) {++ if (!_bellEl) return;++ const badge = _bellEl.querySelector('.badge'), n = _unreadCount + _msgs;++ if (n > 0) {++ badge.textContent = n > 99 ? '99+' : String(n);++ badge.classList.remove('hidden');++ if (ring) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 700); }++ } else badge.classList.add('hidden');++ const parts = [_unreadCount ? `${_unreadCount} unread` : '', _msgs ? `${_msgs} new ${_msgs === 1 ? 'message' : 'messages'}` : ''].filter(Boolean);++ _bellEl.setAttribute('aria-label', parts.length ? `Notifications, ${parts.join(', ')}` : 'Notifications');++ }function setBadge(count, { quiet = false } = {}) {const prev = _unreadCount;_unreadCount = Math.max(0, Number(count) || 0);-- if (_bellEl) {-- const badge = _bellEl.querySelector('.badge');-- if (_unreadCount > 0) {-- badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);-- badge.classList.remove('hidden');-- if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 700); }-- } else badge.classList.add('hidden');-- _bellEl.setAttribute('aria-label', _unreadCount ? `Notifications, ${_unreadCount} unread` : 'Notifications');-- }++ paintBell(!quiet && _unreadCount > prev);for (const v of _views.values()) {const c = v.root.querySelector('.openvibe-nv-head .count');if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }@@@ -1444,6 +1449,10 @@},/** What a tap on the bell does instead of its own panel: fn() returns true when it handled it (navbar.js: the Dock). */setBellAction(fn) { _bellAction = typeof fn === 'function' ? fn : null; },++ /** New messages, counted on the bell with the notifications (navbar.js): 0 takes them off. */++ setMessages(count) { const prev = _msgs; _msgs = Math.max(0, Number(count) || 0); paintBell(_msgs > prev); },++ /** How many notifications are unread (not counting messages). */++ unread() { return _unreadCount; },/** Create and inject the bell icon. Returns the DOM element. */createBell(container) {injectStyles();@@@ -113,6 +113,15 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a.ovh-hint { margin: 10px 6px 4px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }.ovh-calm { transition: opacity .2s; }.ovh.is-edit .ovh-calm { opacity: .38; pointer-events: none; }++.ovh-headtools { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }++.ovh-sec .ovh-allbtn { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 11px 0 10px; border-radius: 999px; cursor: pointer; font-size: 13px; font-weight: 700;++ color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 7%, transparent); transition: color .15s, background-color .15s, transform .12s; }++.ovh-sec .ovh-allbtn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++.ovh-sec .ovh-allbtn b { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8ecf3); }++.ovh-sec .ovh-allbtn:hover { color: var(--text-primary, #e8ecf3); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 13%, transparent); }++.ovh-sec .ovh-allbtn:active { transform: scale(.96); }++.ovh-sec .ovh-allbtn:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++@media (pointer: coarse) { .ovh-sec .ovh-allbtn { min-height: 44px; padding: 0 13px 0 12px; } }.ovh-all[hidden], .ovh[hidden] { display: none !important; }.ovh-all { display: flex; align-items: center; justify-content: center; gap: 8px; width: calc(100% - 8px); min-height: 44px; margin: 6px 4px 2px; border: 1px solid var(--border, rgba(255,255,255,.12));border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }@@@ -326,6 +335,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durconst ICON = {pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],++ grid: [['rect', { x: 4, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 4, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }]],look: [['path', { d: 'M12 3a9 9 0 1 0 0 18c1 0 1.7-.8 1.7-1.7 0-.5-.2-.9-.5-1.2-.3-.3-.4-.7-.4-1.1 0-1 .8-1.7 1.7-1.7h2.1A4.4 4.4 0 0 0 21 11c0-4.4-4-8-9-8Z' }],['circle', { cx: 7.6, cy: 11.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 10.4, cy: 7.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 15, cy: 7.6, r: 1.1, fill: 'currentColor', stroke: 'none' }]],gear: [['circle', { cx: 12, cy: 12, r: 3 }], ['path', { d: 'M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z' }]],@@@ -497,16 +507,20 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-duredit.title = state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders';edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');edit.addEventListener('click', () => setEdit(!state.edit));-- head.append(edit);++ // All apps sits beside Edit, small, with its count (the owner, 2026-11-10: "all apps should just be in a++ // button in the corner so it's more compact"): it was a bar the width of the sheet under the grid.++ const total = L.byId.size;++ const all = h('button', 'ovh-allbtn'); all.type = 'button';++ all.append(svgIcon('grid'), h('span', '', 'All'), h('b', '', String(total)));++ all.setAttribute('aria-label', `All apps (${total})`); all.title = `All apps (${total})`;++ all.addEventListener('click', () => { state.view = 'drawer'; paint(); });++ const tools = h('div', 'ovh-headtools'); tools.append(all, edit);++ head.append(tools);el.append(head);const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');L.home.forEach((id, i) => grid.append(homeTile(id, i)));el.append(grid);-- if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All apps puts it back.'));-- const total = L.byId.size;-- const all = h('button', 'ovh-all', `All apps (${total})`); all.type = 'button';-- all.addEventListener('click', () => { state.view = 'drawer'; paint(); });-- el.append(all);++ if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All puts it back.'));// Under your home: what you use most (shortcuts), then where you were last (history).{ const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }icons();@@@ -701,7 +715,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durfunction backHead(title) {const head = h('div', 'ovh-head');const back = h('button', 'ovh-back', '← Home'); back.type = 'button';-- back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-all'); if (b) b.focus(); });++ back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });head.append(back, h('b', '', title));return head;}@@@ -56,6 +56,9 @@if (window.__ovMark) return; window.__ovMark = true;let n = 0;const f = (v) => +v.toFixed(2);++ // The ground around the V where it crosses the O: the wide gap (the owner's pick, 2026-11-10, the Arena's P07:++ // "more ground around the V where it crosses"); it was 7.++ const HALO = 14;// The Pair in a 100-unit box, centred on (50, 50) (openvibe-shared/mark: the same numbers; test/mark.test.js).function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;@@@ -73,7 +76,7 @@err: [mx, A[1], mx, T[1] - vsw * 1.28, mx, T[1], mx, T[1] + 0.01],};for (const k in shapes) shapes[k] = shapes[k].map(f);-- return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + 7, A, T, B, ring, box, wideBox, shapes };++ return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + HALO, A, T, B, ring, box, wideBox, shapes };}const FORMS = { pair: pair(20, 13, 13), heavy: pair(18, 18, 17) };const formOf = (v) => (v === 'heavy' || v === 'solid' ? 'heavy' : 'pair');@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.186.0",++ "version": "4.187.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.186.0",++ "version": "4.187.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.186.0",++ "version": "4.187.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -101,7 +101,6 @@"./tokens": "./tokens.js","./components": "./components.js","./appbar": "./appbar.js",-- "./site-switcher": "./site-switcher.js","./search": "./search.js","./agent-session": "./agent-session.js","./loading": "./loading.js",@@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the// owner still saw the old menu hours after the new one shipped (11-07).const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',-- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',++ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',// 4.173.0: the wallet sheet (4.170.0) went out without ?v=, so a fix to it reached phones up to a day late.'ov-wallet.js', 'ov-wallet.css'];// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.@@@ -382,13 +382,6 @@.ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }.ovs.is-closing { animation: ovs-fade .28s ease-in both; }}--.ovs-orb .ovo-g { transform-origin: 12px 12px; transform: rotate(45deg) scale(.8); transition: transform .5s cubic-bezier(.2, 1.35, .3, 1); }--.ovs-orb .ovo-d { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: fill .3s, opacity .25s, transform .32s cubic-bezier(.2, 1.4, .3, 1); }--.ovs-orb .ovo-r0 { fill: var(--ovo-a); }--.ovs-orb .ovo-r2 { opacity: .6; }--.ovs-orb.is-x .ovo-g { transform: rotate(180deg) scale(.95); }--.ovs-orb.is-x .ovo-r1 { opacity: 0; transform: scale(0); }--.ovs-orb.is-x :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; transform: scale(1.12); }`;document.head.appendChild(st);}@@@ -403,11 +396,10 @@grid: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></svg>',};-- // The orb (navbar.js draws the same nine dots): on a phone the menu covers the bar, so the menu draws the orb in the++ // The orb (navbar.js's drawing, root.OpenVibeOrb): on a phone the menu covers the bar, so the menu draws the orb in the// button's own place, on top, and it turns into the menu's close (4.150.0, the owner: "it renders on top … turns into// the close button when it's open … a cool effect as it goes in and out").-- const ORB_DOTS = [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]];-- const orbSvg = () => `<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><g class="ovo-g">${ORB_DOTS.map(([x, y, r]) => `<circle class="ovo-d ovo-r${r}" cx="${x}" cy="${y}" r="${r ? 2.15 : 2.5}"/>`).join('')}</g></svg>`;++ const orbSvg = () => (root.OpenVibeOrb ? root.OpenVibeOrb.svg(24) : '');let orbEl = null, closing = 0;const phoneSheet = () => { try { return !!(el && el.classList.contains('ovs-anchored') && root.matchMedia('(max-width: 640px)').matches); } catch { return false; } };// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.ddeleted file mode 100644@@@ -1,249 +0,0 @@--/**-- * site-switcher.js — the site switcher behind the OpenVibe icon (4.48.0).-- *-- * The owner, 2026-10-30: "clicking the icon … should have its own drop-down that lets you very quickly … a more compact-- * view of all the OpenVibe domains … tools and services … all the sites, so you can hop between them fast and easy.-- * And make it ordered based on whatever makes sense." The navbar loads this on the first tap of its mark (as it loads-- * search.js for the menu) and calls OpenVibeSiteSwitcher.open().-- *-- * Order:-- * - Recent: the sites this person was on last (their account's list when signed in, from-- * /api/themes/me/sites; otherwise the sites they jumped to from here, kept in this site's storage).-- * - Pinned: the sites they pinned (hold a site, or right-click it).-- * - Then every open site, grouped by what people come to do (the frame's launcher groups). Inside a group the frame's-- * order is kept: most used first.-- *-- * Use:-- * - Type to filter. The arrow keys move, Enter goes, Esc closes.-- * - On phones it is a sheet under the bar that keeps its own scroll and locks the page behind it.-- * - The site's name in the bar stays its link home.-- *-- * The switcher is DOM nodes only: no text from the frame is parsed as HTML, and links must be https.-- */--(function (root) {-- 'use strict';-- if (typeof document === 'undefined' || root.OpenVibeSiteSwitcher) return;---- const STYLE_ID = 'ov-switcher-style';-- const LOCAL = 'ov_sw_sites';-- const SITE_ID = /^[a-z][a-z0-9-]{1,31}$/;-- const ACCOUNT = 'https://openvibe.network/my';-- const CSS = `--.ovsw { position: fixed; inset: 0; z-index: 10021; display: none; background: rgba(3, 6, 12, .32); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }--.ovsw.is-open { display: block; }--.ovsw-panel { position: fixed; top: var(--ovsw-top, 64px); left: var(--ovsw-left, 8px); width: min(560px, calc(100vw - 16px)); max-height: calc(100dvh - var(--ovsw-top, 64px) - 12px); overflow-y: auto; overscroll-behavior: contain; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 12px; box-sizing: border-box; border-radius: 20px; background: var(--bg-secondary, #101828); color: var(--text-primary, #e6edf7); box-shadow: 0 0 0 1px var(--border-light, rgba(255,255,255,.12)), 0 22px 60px rgba(0,0,0,.45); transform-origin: 24px -6px; animation: ovsw-in .34s cubic-bezier(.2, 1.2, .3, 1) both; }--@media (max-width: 640px) { .ovsw-panel { left: 0; width: 100vw; border-radius: 0 0 22px 22px; } }--.ovsw-top { display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 8px 0 12px; border-radius: 13px; background: var(--bg-primary, #0a0f1c); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }--.ovsw-top:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }--.ovsw-top svg { width: 17px; height: 17px; flex: none; fill: none; stroke: var(--text-muted, #8192ac); stroke-width: 2.2; stroke-linecap: round; }--.ovsw-top input { all: unset; flex: 1; min-width: 0; font-size: 15px; color: var(--text-primary, #e6edf7); }--.ovsw-top input::placeholder { color: var(--text-muted, #8192ac); }--.ovsw-top input::-webkit-search-cancel-button { display: none; }--.ovsw-top kbd { font: 600 10.5px/1 inherit; padding: 4px 6px; border-radius: 6px; color: var(--text-muted, #8192ac); box-shadow: inset 0 0 0 1px var(--border-light, rgba(255,255,255,.14)); }--@media (pointer: coarse) { .ovsw-top kbd { display: none; } }--.ovsw-h { display: flex; justify-content: space-between; gap: 8px; margin: 0; padding: 0 4px 4px; font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted, #8192ac); }--.ovsw-h span { font-weight: 500; letter-spacing: 0; text-transform: none; }--.ovsw-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(68px, 1fr)); gap: 2px; }--.ovsw-row { position: relative; display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }--.ovsw-row::-webkit-scrollbar { display: none; }--.ovsw-row .ovsw-t { flex: 0 0 72px; }--.ovsw-t { all: unset; box-sizing: border-box; position: relative; display: grid; justify-items: center; gap: 5px; padding: 8px 2px 7px; border-radius: 13px; cursor: pointer; font-family: 'OV Brand', Nunito, 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-size: 12px; font-weight: 800; line-height: 1.15; text-align: center; color: var(--text-secondary, #a8b3c4); -webkit-touch-callout: none; user-select: none; animation: ovsw-tile .36s cubic-bezier(.2, 1.3, .3, 1) both; animation-delay: calc(var(--k, 0) * 10ms + 50ms); }--.ovsw-t:hover, .ovsw-t:focus-visible { outline: none; color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }--.ovsw-t:focus-visible { box-shadow: inset 0 0 0 1.5px var(--accent, #3b82f6); }--.ovsw-t .ov-icon { width: 36px; height: 36px; transition: transform .26s cubic-bezier(.2, 1.4, .3, 1); }--.ovsw-t:hover .ov-icon { transform: translateY(-2px) scale(1.06); }--.ovsw-t.is-here .ov-icon { border-radius: 11px; box-shadow: 0 0 0 2px var(--bg-secondary, #101828), 0 0 0 4px var(--accent, #3b82f6); }--.ovsw-t.is-here::after { content: 'here'; position: absolute; top: 2px; right: 6px; font-size: 9px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); }--.ovsw-pin { position: absolute; top: 2px; left: 6px; font-size: 10px; color: #f59e0b; }--.ovsw-none { margin: 0; padding: 8px 4px; font-size: 14px; color: var(--text-secondary, #a8b3c4); }--.ovsw-foot { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 2px 4px 0; font-size: 12.5px; color: var(--text-muted, #8192ac); }--.ovsw-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 650; text-decoration: none; }--.ovsw-foot a:hover { text-decoration: underline; }--@keyframes ovsw-in { from { opacity: 0; transform: translateY(-8px) scale(.97); } }--@keyframes ovsw-tile { from { opacity: 0; transform: translateY(6px) scale(.94); } }--`;---- let el = null, panel = null, ctx = {}, lastFocus = null, mine = null, query = '';-- const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };-- const ids = (a, n) => [...new Set((Array.isArray(a) ? a : []).map(String).filter((x) => SITE_ID.test(x)))].slice(0, n);-- function localSites() {-- try { const d = JSON.parse(localStorage.getItem(LOCAL) || 'null') || {}; return { pinned: ids(d.pinned, 12), recent: ids(d.recent, 8) }; } catch { return { pinned: [], recent: [] }; }-- }-- function saveLocal(d) { try { localStorage.setItem(LOCAL, JSON.stringify({ pinned: ids(d.pinned, 12), recent: ids(d.recent, 8) })); } catch { /* storage may be off */ } }-- function headers(json) {-- const h = { Accept: 'application/json' };-- if (json) h['Content-Type'] = 'application/json';-- if (ctx.token) h.Authorization = 'Bearer ' + ctx.token;-- return h;-- }-- const signedIn = () => !!(ctx.user && ctx.apiBase);-- function loadMine() {-- if (!signedIn() || typeof fetch === 'undefined') return Promise.resolve(localSites());-- return fetch(ctx.apiBase + '/api/themes/me/sites', { headers: headers(), credentials: 'include' })-- .then((r) => (r.ok ? r.json() : null))-- .then((d) => (d ? { pinned: ids(d.pinned, 12), recent: ids(d.recent, 8) } : localSites()))-- .catch(() => localSites());-- }-- function savePins(pinned) {-- const cur = mine || localSites();-- mine = { pinned: ids(pinned, 12), recent: cur.recent };-- saveLocal(mine);-- if (signedIn() && typeof fetch !== 'undefined') fetch(ctx.apiBase + '/api/themes/me/sites', { method: 'PUT', headers: headers(true), credentials: 'include', body: JSON.stringify({ pinned: mine.pinned }) }).catch(() => {});-- }-- function noteJump(id) {-- const cur = localSites();-- saveLocal({ pinned: cur.pinned, recent: [id, ...cur.recent] });-- }---- function style() {-- if (document.getElementById(STYLE_ID)) return;-- const s = document.createElement('style'); s.id = STYLE_ID; s.textContent = CSS; document.head.appendChild(s);-- }-- function sites() {-- const f = ctx.frame || {};-- return (Array.isArray(f.nav) ? f.nav : []).filter((s) => s && SITE_ID.test(s.id) && https(s.url));-- }-- function lens() {-- const ns = 'http://www.w3.org/2000/svg', svg = document.createElementNS(ns, 'svg');-- svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true');-- const c = document.createElementNS(ns, 'circle'); c.setAttribute('cx', '11'); c.setAttribute('cy', '11'); c.setAttribute('r', '7');-- const p = document.createElementNS(ns, 'path'); p.setAttribute('d', 'm21 21-4.3-4.3');-- svg.append(c, p); return svg;-- }-- function build() {-- style();-- el = document.createElement('div');-- el.className = 'ovsw';-- el.addEventListener('pointerdown', (e) => { if (e.target === el) close(); });-- panel = document.createElement('div');-- panel.className = 'ovsw-panel';-- panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); panel.setAttribute('aria-label', 'Switch to another OpenVibe site');-- panel.addEventListener('keydown', onKey);-- el.appendChild(panel);-- document.body.appendChild(el);-- }-- // The page's own site: by its address (one service can serve several sites: media-hub is Video, Pics and Download).-- const isHere = (s) => { try { return new URL(s.url).hostname === location.hostname; } catch { return s.id === ctx.service; } };-- function tile(s, k, opts) {-- const here = isHere(s);-- const b = document.createElement('a');-- b.className = 'ovsw-t' + (here ? ' is-here' : '');-- b.href = s.url;-- b.style.setProperty('--k', String(k));-- b.title = s.tagline ? `${s.name}: ${s.tagline}` : s.name;-- if (here) b.setAttribute('aria-current', 'page');-- const ic = document.createElement('span'); ic.className = 'ov-icon'; ic.setAttribute('data-icon', s.id); ic.setAttribute('data-size', '36'); ic.setAttribute('aria-hidden', 'true');-- b.appendChild(ic);-- b.appendChild(document.createTextNode(s.name));-- if (opts && opts.pinned) { const p = document.createElement('span'); p.className = 'ovsw-pin'; p.textContent = '★'; p.setAttribute('aria-label', 'pinned'); b.appendChild(p); }-- // Hold (touch) or right-click: pin or unpin.-- let hold = 0, held = false;-- const toggle = () => { const cur = (mine || localSites()).pinned; savePins(cur.includes(s.id) ? cur.filter((x) => x !== s.id) : [s.id, ...cur]); render(); };-- b.addEventListener('pointerdown', (e) => { held = false; if (e.pointerType !== 'mouse') hold = setTimeout(() => { held = true; toggle(); }, 480); });-- b.addEventListener('pointerup', () => clearTimeout(hold));-- b.addEventListener('pointerleave', () => clearTimeout(hold));-- b.addEventListener('contextmenu', (e) => { e.preventDefault(); toggle(); });-- b.addEventListener('click', (e) => { if (held) { e.preventDefault(); held = false; return; } if (here) { e.preventDefault(); close(); return; } noteJump(s.id); });-- return b;-- }-- function section(title, hint, list, row, pinnedSet) {-- const wrap = document.createElement('section');-- const h = document.createElement('p'); h.className = 'ovsw-h'; h.textContent = title;-- if (hint) { const sp = document.createElement('span'); sp.textContent = hint; h.appendChild(sp); }-- const grid = document.createElement('div'); grid.className = row ? 'ovsw-row' : 'ovsw-grid';-- list.forEach((s, k) => grid.appendChild(tile(s, k, { pinned: pinnedSet && pinnedSet.has(s.id) })));-- wrap.append(h, grid);-- return wrap;-- }-- function render() {-- if (!panel) return;-- const keep = panel.querySelector('input');-- const hadFocus = keep && document.activeElement === keep;-- const pos = keep ? keep.selectionStart : null;-- panel.textContent = '';-- const top = document.createElement('label'); top.className = 'ovsw-top';-- const input = document.createElement('input');-- input.type = 'search'; input.placeholder = 'Jump to a site'; input.autocomplete = 'off'; input.setAttribute('aria-label', 'Filter the sites'); input.value = query;-- input.addEventListener('input', () => { query = input.value; render(); });-- const kbd = document.createElement('kbd'); kbd.textContent = 'Esc';-- top.append(lens(), input, kbd);-- panel.appendChild(top);-- const all = sites(), byId = new Map(all.map((s) => [s.id, s]));-- const m = mine || localSites(), pinnedSet = new Set(m.pinned);-- const q = query.trim().toLowerCase();-- const groups = (ctx.frame && Array.isArray(ctx.frame.groups) && ctx.frame.groups.length) ? ctx.frame.groups : [{ id: '', name: 'OpenVibe' }];-- if (!all.length) {-- const p = document.createElement('p'); p.className = 'ovsw-none'; p.textContent = 'Loading the sites…'; panel.appendChild(p);-- } else if (q) {-- const hits = all.filter((s) => s.name.toLowerCase().includes(q) || s.id.includes(q) || String(s.tagline || '').toLowerCase().includes(q));-- if (hits.length) panel.appendChild(section(`${hits.length} ${hits.length === 1 ? 'site' : 'sites'}`, 'Enter opens the first', hits, false, pinnedSet));-- else { const p = document.createElement('p'); p.className = 'ovsw-none'; p.textContent = `No site called "${query.trim()}".`; panel.appendChild(p); }-- } else {-- const recent = m.recent.map((id) => byId.get(id)).filter((s) => s && !isHere(s));-- if (recent.length) panel.appendChild(section('Recent', signedIn() ? 'where you were last' : 'where you went from here', recent, true));-- const pinned = m.pinned.map((id) => byId.get(id)).filter(Boolean);-- panel.appendChild(pinned.length ? section('Pinned', 'hold a site to unpin it', pinned, false, pinnedSet) : (() => { const p = document.createElement('p'); p.className = 'ovsw-h'; p.textContent = 'Pinned'; const sp = document.createElement('span'); sp.textContent = 'hold a site (or right-click) to pin it'; p.appendChild(sp); return p; })());-- // Each group in the frame's order, most used first inside it; a site in a group this release does not know-- // joins the last group (as the menu's Sites tab does).-- const known = new Set(groups.map((g) => g.id));-- groups.forEach((g, gi) => {-- const list = all.filter((s) => (g.id ? s.group === g.id : true) || (gi === groups.length - 1 && !known.has(s.group)));-- if (list.length) panel.appendChild(section(g.name, '', list, false, pinnedSet));-- });-- }-- const foot = document.createElement('div'); foot.className = 'ovsw-foot';-- const count = document.createElement('span'); count.textContent = `${all.length || ''}${all.length ? ' sites · ' : ''}one account`;-- const acct = document.createElement('a'); acct.href = ACCOUNT; acct.textContent = 'Your account →';-- foot.append(count, acct);-- panel.appendChild(foot);-- if (typeof ctx.icons === 'function') { try { ctx.icons(panel); } catch { /* icons are optional */ } }-- input.addEventListener('keydown', (e) => {-- if (e.key === 'Enter') { const first = panel.querySelector('.ovsw-t:not(.is-here)'); if (first) { e.preventDefault(); first.click(); } }-- if (e.key === 'ArrowDown') { const first = panel.querySelector('.ovsw-t'); if (first) { e.preventDefault(); first.focus(); } }-- });-- if (hadFocus) { input.focus(); try { input.setSelectionRange(pos, pos); } catch { /* */ } }-- }-- function onKey(e) {-- if (e.key === 'Escape') { e.preventDefault(); close(); return; }-- const tiles = [...panel.querySelectorAll('.ovsw-t')], i = tiles.indexOf(document.activeElement);-- if (i < 0) return;-- const row = tiles[i].parentElement, cols = Math.max(1, Math.round(row.clientWidth / Math.max(1, tiles[i].offsetWidth)));-- const go = (j) => { if (tiles[j]) { e.preventDefault(); tiles[j].focus(); } };-- if (e.key === 'ArrowRight') go(i + 1);-- else if (e.key === 'ArrowLeft') go(i - 1);-- else if (e.key === 'ArrowDown') go(Math.min(tiles.length - 1, i + cols));-- else if (e.key === 'ArrowUp') { if (i - cols < 0) { e.preventDefault(); const inp = panel.querySelector('input'); if (inp) inp.focus(); } else go(i - cols); }-- }---- function open(opts) {-- ctx = opts || {};-- if (!el) build();-- lastFocus = document.activeElement;-- query = '';-- const at = ctx.anchor;-- el.style.setProperty('--ovsw-top', `${Math.max(0, Math.round(at && at.top || 64))}px`);-- el.style.setProperty('--ovsw-left', `${Math.max(8, Math.round(at && at.left || 8))}px`);-- el.classList.add('is-open');-- document.documentElement.style.overflow = 'hidden';-- try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true, switcher: true } })); } catch { /* */ }-- render();-- // A pointer that can hover gets the filter focused; a phone keeps its keyboard down until someone taps it.-- if (typeof matchMedia === 'function' && matchMedia('(pointer: fine)').matches) { const inp = panel.querySelector('input'); if (inp) setTimeout(() => inp.focus(), 0); }-- else setTimeout(() => { const t = panel.querySelector('.ovsw-t'); if (t && t.focus) t.focus({ preventScroll: true }); }, 0);-- loadMine().then((d) => { mine = d; if (isOpen()) render(); });-- }-- function update(frame) { if (frame) { ctx.frame = frame; if (isOpen()) render(); } }-- function close() {-- if (!el || !el.classList.contains('is-open')) return;-- el.classList.remove('is-open');-- document.documentElement.style.overflow = '';-- if (typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }-- try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: false, switcher: true } })); } catch { /* */ }-- if (lastFocus && lastFocus.focus) lastFocus.focus();-- }-- const isOpen = () => !!(el && el.classList.contains('is-open'));---- const api = { open, close, update, isOpen };-- root.OpenVibeSiteSwitcher = api;-- if (typeof module === 'object' && module.exports) module.exports = api;--})(typeof globalThis !== 'undefined' ? globalThis : this);@@@ -34,7 +34,6 @@ assert.strictEqual(files.path('ov-brand.woff2'), path.join(__dirname, '..', 'ov-const fallback = /font-family: ?'OV Brand', ?Nunito, ?'?Inter'?, ?-apple-system/;assert.match(nav, new RegExp(`\\.openvibe-navbar-brand \\.name \\{[^}]*${fallback.source}`), 'the site name');assert.match(read('footer.js'), new RegExp(`\\.ovf-brand\\{[^}]*${fallback.source}`), "the footer's brand");-- assert.match(read('site-switcher.js'), new RegExp(`\\.ovsw-t \\{[^}]*${fallback.source}`), "the switcher's names");const cards = read('scripts/build-social-cards.js');assert.ok(/function brandFace\(\)[\s\S]*ov-brand\.woff2/.test(cards) && /\.name\{[^}]*font-family:'OV Brand'/.test(cards), 'the share cards, with the face embedded');@@@ -21,5 +21,4 @@ files.BROWSER.forEach((name) => {}});assert.deepStrictEqual([...new Set(missing)], [], 'files a browser file loads but no site serves');--assert.ok(files.BROWSER.includes('site-switcher.js'), 'the mark\'s switcher is served');console.log('browser files complete: all checks passed');@@@ -38,7 +38,7 @@ for (const form of ['pair', 'heavy']) {// The standalone SVG: the gap is a mask, so it holds on any ground; the tones; the wide box.const svg = mark.svg({ size: 64, title: 'OpenVibe', depth: false }); // the flat colours (depth is checked below)assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="[\d. ]+" width="64" height="64" role="img" aria-label="OpenVibe"><title>OpenVibe<\/title>/);--assert.match(svg, /<mask id="ovmh"[\s\S]*stroke="#000" stroke-width="20"[\s\S]*<\/mask>[\s\S]*<circle [^>]*stroke="#4f7cff"[^>]*mask="url\(#ovmh\)"\/><path [^>]*stroke="#8b5cf6"/, 'blue O cut by a mask, violet V on top');++assert.match(svg, /<mask id="ovmh"[\s\S]*stroke="#000" stroke-width="27"[\s\S]*<\/mask>[\s\S]*<circle [^>]*stroke="#4f7cff"[^>]*mask="url\(#ovmh\)"\/><path [^>]*stroke="#8b5cf6"/, 'blue O cut by a mask, violet V on top');assert.match(mark.svg({ tone: 'ink' }), /stroke="currentColor"[\s\S]*stroke="currentColor"/, 'ink: the text colour');assert.match(mark.svg({ tone: 'white' }), /stroke="#fff"[\s\S]*stroke="#fff"/, 'white: for a colour tile');const wide = mark.svg({ wide: true, size: 40 });@@@ -68,7 +68,7 @@ async function openMenu(p) {assert.strictEqual(x.vw, 390, 'a phone layout');assert.ok(x.w >= 40 && x.h >= 40 && x.right <= x.vw && x.top >= 0, `a 40 px target on screen: ${JSON.stringify(x)}`);assert.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'the orb, named for a screen reader');-- assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovo-d').length === 9; })()`), true, 'its nine dots have turned into an x');++ assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovmb .ovmb-app').length === 3 && getComputedStyle(o.querySelector('.ovmb-bar')).transform !== 'none'; })()`), true, 'its bar and three apps have crossed into an x');assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb');const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-orb').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-orb'); })()`);assert.ok(hit, 'the hit area reaches past the circle');@@@ -103,7 +103,7 @@ const server = http.createServer((req, res) => {assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches');-- assert.ok(await p.evaluate(`/All apps \\(\\d+\\)/.test(document.querySelector('.ovh-all').textContent)`), 'All apps says how many');++ assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^All\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120; })()`), 'All apps is a small button beside Edit, and says how many');assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip');// A long-press starts Edit, and the same press drags the first app onto the third's place.@@@ -135,7 +135,7 @@ const server = http.createServer((req, res) => {assert.ok(!removed.includes(keyed[0]) && removed.length === 11, '− takes it off Home');// Done, then All apps puts it back.await p.evaluate(`[...document.querySelectorAll('.ovh-sec button')].find((b) => b.textContent === 'Done').click(), true`);-- await p.evaluate(`document.querySelector('.ovh-all').click(), true`);++ await p.evaluate(`document.querySelector('.ovh-allbtn').click(), true`);assert.ok(await until(p, `!!document.querySelector('.ovh-filter')`), 'the drawer opened');await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge.is-add').click(), true`);await p.evaluate(`document.querySelector('.ovh-back').click(), true`);@@@ -199,7 +199,7 @@ const server = http.createServer((req, res) => {assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is');assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');-- await s.evaluate(`document.querySelector('.ovh-sec button').click(), true`);++ await s.evaluate(`document.querySelector('.ovh-sec .ovh-edit').click(), true`);await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);@@@ -46,11 +46,11 @@ const MEASURE = `(() => {const box = (el) => { const r = el.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right)]; };const name = bar.querySelector('.openvibe-navbar-brand .name');const parts = [['name', name], ['launcher', bar.querySelector('.ovnav-launch')], ['search', bar.querySelector('#openvibe-search-btn')],-- ['balances', bar.querySelector('.ovnav-chip--all')], ['sign in', bar.querySelector('#openvibe-login-btn')],-- ['picture', bar.querySelector('#openvibe-avatar-btn')], ['menu', bar.querySelector('#openvibe-burger')]].filter(([, el]) => vis(el)).map(([k, el]) => [k, box(el)]);++ ['balances', bar.querySelector('.ovnav-chip--all')], ['menu', bar.querySelector('#openvibe-burger')],++ ['sign in', bar.querySelector('#openvibe-login-btn')], ['picture', bar.querySelector('#openvibe-avatar-btn')]].filter(([, el]) => vis(el)).map(([k, el]) => [k, box(el)]);const minis = [...bar.querySelectorAll('.ovnav-chip--all .ovnav-chip-mini')].filter(vis).length;const menu = bar.querySelector('#openvibe-burger');-- const orbMenu = !!(menu && menu.classList.contains('ovnav-orb') && menu.querySelectorAll('.ovo-g .ovo-d').length === 9 && !menu.querySelector('span'));++ const orbMenu = !!(menu && menu.classList.contains('ovnav-orb') && menu.querySelectorAll('.ovmb .ovmb-app').length === 3 && !!menu.querySelector('.ovmb-bar') && !menu.querySelector('span'));return { parts, clipped: name.scrollWidth > name.clientWidth + 1, visibleText: name.innerText, minis, width: innerWidth, orbMenu, docW: document.documentElement.scrollWidth };})()`;@@@ -24,13 +24,14 @@ function load(answers) {global.window = { location: global.location };try { Object.defineProperty(global, 'navigator', { value: { userAgent: 'test' }, configurable: true }); } catch { /* */ }const navs = [];-- const badge = Object.assign(stubElement(), { parentElement: stubElement() });-- const messageButton = stubElement();++ // The bell takes the count of new messages (4.187.0: there is no Messages button).++ const bell = [];++ global.OpenVibeNotifications = { setMessages: (n) => bell.push(n), unread: () => 0, setBellAction() {} };global.document = {cookie: '', head: stubElement(), body: stubElement(), title: 'T', visibilityState: 'visible',getElementById: () => null, querySelectorAll: () => [],-- querySelector: (sel) => (sel === '#openvibe-messages-btn b' ? badge : null),-- createElement: (tag) => { const e = stubElement(); e.tag = tag; if (tag === 'nav') { e.querySelector = (sel) => sel === '#openvibe-messages-btn' ? messageButton : stubElement(); navs.push(e); } return e; },++ querySelector: () => null,++ createElement: (tag) => { const e = stubElement(); e.tag = tag; if (tag === 'nav') { e.querySelector = () => stubElement(); navs.push(e); } return e; },addEventListener() {}, removeEventListener() {}, dispatchEvent() {},};global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };@@@ -48,7 +49,7 @@ function load(answers) {return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };};delete require.cache[require.resolve(NAVBAR_PATH)];-- return { navbar: require(NAVBAR_PATH), navs, calls, badge, messageButton };++ return { navbar: require(NAVBAR_PATH), navs, calls, bell };}const settle = () => new Promise((r) => setTimeout(r, 20));const user = { id: 7, subject: 'usr_7', username: 'maya' };@@@ -57,37 +58,27 @@ const answers = { coins: { status: 200, body: { balances: { coins: { amount: '12const asked = (calls, re) => calls.filter((u) => re.test(u));(async () => {-- // Nothing said: the balances and Messages, and one question to Chat for the unread count.-- let { navbar, navs, calls, badge, messageButton } = load(answers);++ // Nothing said: the balances, and one question to Chat for the unread count, which goes to the bell.++ let { navbar, navs, calls, bell } = load(answers);navbar.init(base);-- const dockOpens = [];-- global.OpenVibeDock = { mount() {}, open(options) { dockOpens.push(options); } };-- let prevented = false;-- messageButton.listeners.click({ preventDefault() { prevented = true; }, metaKey: false, ctrlKey: false, shiftKey: false });-- assert.strictEqual(prevented, true, 'signed-in Messages stays on this page');-- assert.deepStrictEqual(dockOpens, [{ tab: 'chats' }], 'the default action opens the Dock Chats tab');let bar = navs[navs.length - 1].innerHTML;-- assert.match(bar, /id="openvibe-messages-btn"/, 'Messages is in the bar');-- assert.match(bar, /href="https:\/\/openvibe\.chat\/messages"/, 'and is a real link (a site without the Dock follows it)');++ assert.doesNotMatch(bar, /openvibe-messages-btn|ovnav-msgs/, 'no Messages button in the bar: the bell counts them');++ assert.ok(bar.indexOf('id="openvibe-bell-mount"') < bar.indexOf('id="openvibe-avatar-btn"'), 'the bell, then the person: the picture is the last thing in the bar');assert.match(bar, /data-chip-id="coins"/, 'the OpenCoins chip');assert.match(bar, /data-chip-id="vibes"/, 'the Vibes chip');await settle();assert.deepStrictEqual(asked(calls, /\/api\/dm\/unread$/), ['https://openvibe.chat/api/dm/unread'], 'Chat is asked for the count');assert.strictEqual(asked(calls, /\/api\/frame\/wallet\/api\/v1\/me\/wallet$/).length, 1, 'and the Network (its wallet relay) for the balances');-- assert.strictEqual(badge.textContent, '3', 'the count shows on the button');++ assert.deepStrictEqual(bell, [3], 'the count goes to the bell');navbar.init(base);await settle();assert.strictEqual(asked(calls, /\/api\/dm\/unread$/).length, 1, 'at most once a minute');// A site with an HTTP-only session can hand its verified token back from /auth/me.-- ({ navbar, navs, calls, messageButton } = load({ ...answers, session: { status: 200, body: { user, token: 'session-token' } } }));++ ({ navbar, navs, calls } = load({ ...answers, session: { status: 200, body: { user, token: 'session-token' } } }));navbar.init({ service: 'actor', sessionUrl: '/auth/me', notifications: false, wallet: false });await settle();assert.strictEqual(navbar.session.token, 'session-token', 'the navbar receives the site session token');-- const fromSession = [];-- global.OpenVibeDock = { mount() {}, open(options) { fromSession.push(options); } };-- messageButton.listeners.click({ preventDefault() {}, metaKey: false, ctrlKey: false, shiftKey: false });-- assert.deepStrictEqual(fromSession, [{ tab: 'chats' }], 'the site session opens Chats in place');({ navbar, calls } = load({ ...answers, session: { status: 200, body: { user } }, sessionToken: { status: 200, body: { token: 'short-access-token' } } }));navbar.init({ service: 'actor', sessionUrl: '/auth/me', tokenUrl: '/auth/navbar-token', notifications: false, wallet: false });@@@ -95,15 +86,6 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));assert.strictEqual(navbar.session.token, 'short-access-token', 'HTTP-only sessions use the same-origin token route');assert.ok(calls.includes('/auth/navbar-token'), 'the token route is requested after the session identifies the person');-- // The button can be pressed while the signed-in page is still resolving its HTTP-only session.-- ({ navbar, messageButton } = load({ ...answers, session: { status: 200, body: { user } }, sessionToken: { status: 200, body: { token: 'late-token' } } }));-- navbar.init({ service: 'actor', user, token: null, sessionUrl: '/auth/me', tokenUrl: '/auth/navbar-token', notifications: false, wallet: false });-- const afterWait = [];-- global.OpenVibeDock = { mount() {}, open(options) { afterWait.push(options); } };-- messageButton.listeners.click({ preventDefault() {}, metaKey: false, ctrlKey: false, shiftKey: false });-- await settle();-- assert.deepStrictEqual(afterWait, [{ tab: 'chats' }], 'a click during session loading opens Chats when the token arrives');--// A site that says no.({ navbar, navs, calls } = load(answers));navbar.init({ ...base, wallet: false, messages: false });@@@ -119,18 +101,19 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));assert.doesNotMatch(navs.map((n) => n.innerHTML).join(''), /openvibe-messages-btn|data-chip-id="coins"/);assert.strictEqual(asked(calls, /\/api\/(dm\/unread|coins\/me|frame\/wallet)/).length, 0);-- // A site with its own Messages (Live, Chat) keeps its own count: Chat is not asked by the navbar.++ // A site with its own Messages (Live, Chat) hands its own count to setMessages: Chat is not asked by the navbar.({ navbar, navs, calls } = load(answers));navbar.init({ ...base, messages: { href: '/messages' } });await settle();-- assert.match(navs[navs.length - 1].innerHTML, /href="\/messages"/);assert.strictEqual(asked(calls, /\/api\/dm\/unread$/).length, 0);-- // Chat away: the button stays, without a number.-- ({ navbar, navs, calls, badge } = load({ ...answers, unread: { status: 503, body: {} } }));++ // Chat away: nothing is counted, and nothing breaks.++ ({ navbar, navs, calls, bell } = load({ ...answers, unread: { status: 503, body: {} } }));navbar.init(base);await settle();-- assert.match(navs[navs.length - 1].innerHTML, /id="openvibe-messages-btn"/);-- assert.strictEqual(badge.textContent, '');++ assert.deepStrictEqual(bell, []);++ // A site's own count reaches the bell the same way.++ navbar.setMessages(5);++ assert.deepStrictEqual(bell, [5]);console.log('navbar-yours-everywhere: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });@@@ -489,7 +489,8 @@ const created = (subject, over = {}) => ({// clear, the phone sheet; quiet hours); measured 22.56 KB.// 24 → 24.5 KB on 2026-11-07 (4.137.0): notification-live.js by its content hash (24.04 KB measured).// 24.5 → 25.5 KB on 2026-11-09 (4.172.0): service faces and readable agent and watch notices (25.24 KB measured).-- assert.ok(size('notification-ui.js') <= 25.5 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 25.5 KB`);++ // 25.5 → 25.7 KB on 2026-11-10 (4.187.0): new messages are counted on the bell (25.51 KB measured).++ assert.ok(size('notification-ui.js') <= 25.7 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 25.7 KB`);console.log('notification live: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });ddeleted file mode 100644@@@ -1,113 +0,0 @@--'use strict';--// The site switcher behind the OpenVibe mark (site-switcher.js, 4.48.0; the owner, 2026-10-30: "clicking the icon …--// its own drop-down … all the sites, so you can hop between them fast"). In a real headless Chrome (skipped without--// Chrome or with OV_SKIP_BROWSER=1), with the Frame and the account's sites served through a stubbed fetch.--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const http = require('http');----const ROOT = path.join(__dirname, '..');--const h = require('../browser-harness');--if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {-- console.log('site-switcher: skipped (no Chrome; set CHROME_BIN)');-- process.exit(0);--}----const GROUPS = [{ id: 'watch', name: 'Watch and stream' }, { id: 'talk', name: 'Talk and share' }, { id: 'build', name: 'Build on OpenVibe' }];--const site = (id, name, group, url = `https://openvibe.${id}/`) => ({ id, name, url, icon: id, tagline: `${name} things`, group });--const nav = [site('live', 'Live', 'watch'), site('video', 'Video', 'watch'), site('chat', 'Chat', 'talk', 'https://127.0.0.1/'),-- site('community', 'Community', 'talk'), site('design', 'Design', 'build'), site('pics', 'Pics', 'watch'), site('mystery', 'Mystery', 'new-group')];--const FRAME = { nav, groups: GROUPS, soon: [], footer: {} };--const MINE = { pinned: ['design'], recent: ['chat', 'video', 'live'] };----const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Switcher</title><link rel="icon" href="data:,">--<script>-- window.__calls = [];-- const realFetch = window.fetch;-- window.fetch = (u, o) => {-- const s = String(u);-- if (s.startsWith('https://openvibe.network/api/frame')) return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) });-- if (s.startsWith('https://openvibe.network/api/themes/me/sites')) {-- window.__calls.push({ url: s, method: (o && o.method) || 'GET', body: o && o.body, auth: o && o.headers && o.headers.Authorization });-- return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(MINE)}) });-- }-- return realFetch(u, o);-- };--</script>--<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>--<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'chat', user: { id: 7, username: 'viewer' }, token: 'tok_test', links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false }); });</script>--</body></html>`;--const server = http.createServer((req, res) => {-- const u = new URL(req.url, 'http://x');-- if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }-- const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);-- if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }-- res.writeHead(404); return res.end();--});----(async () => {-- await new Promise((ok) => server.listen(0, '127.0.0.1', ok));-- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-switcher-'));-- const browser = await h.launch({ tmpDir });-- try {-- const context = await browser.newContext();-- const p = await h.openPage(browser, context, { width: 1280, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });-- await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });-- await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (localStorage.getItem('ov_frame_v2') && document.getElementById('openvibe-switch-btn')) { clearInterval(t); r(true); } }, 20); })`);-- const wait = (ms) => new Promise((r) => setTimeout(r, ms));-- const brand = await p.evaluate(`(() => { const b = document.getElementById('openvibe-switch-btn'); return { tag: b.tagName, popup: b.getAttribute('aria-haspopup'), expanded: b.getAttribute('aria-expanded'), home: !!document.querySelector('.openvibe-navbar-brand .name a[href]') }; })()`);-- assert.deepStrictEqual(brand, { tag: 'BUTTON', popup: 'dialog', expanded: 'false', home: true }, 'the mark opens the switcher; the name stays the link home');-- // The visit waits for an idle moment: wait for it (up to 5 s).-- await p.evaluate(`new Promise((r) => { const t0 = Date.now(); const t = setInterval(() => { if (window.__calls.some((c) => c.method === 'POST') || Date.now() - t0 > 5000) { clearInterval(t); r(true); } }, 50); })`);-- const visits = await p.evaluate(`window.__calls.filter((c) => c.method === 'POST')`);-- assert.strictEqual(visits.length, 1, 'one visit recorded for a signed-in person');-- assert.deepStrictEqual([JSON.parse(visits[0].body).site, visits[0].auth], ['chat', 'Bearer tok_test'], 'this site, with the Network token');---- await p.evaluate(`document.getElementById('openvibe-switch-btn').click(), true`);-- await p.evaluate(`new Promise((r) => { const t = setInterval(() => { const h = [...document.querySelectorAll('.ovsw.is-open .ovsw-h')].map((x) => x.firstChild.textContent); if (h.includes('Recent')) { clearInterval(t); r(true); } }, 20); })`);-- const read = () => p.evaluate(`(() => { const box = document.querySelector('.ovsw-panel'); return {-- open: !!document.querySelector('.ovsw.is-open'),-- expanded: document.getElementById('openvibe-switch-btn').getAttribute('aria-expanded'),-- heads: [...box.querySelectorAll('.ovsw-h')].map((x) => x.firstChild.textContent),-- rows: [...box.querySelectorAll('section')].map((s) => [...s.querySelectorAll('.ovsw-t')].map((t) => t.textContent.replace('★', ''))),-- here: [...box.querySelectorAll('.ovsw-t.is-here')].map((t) => t.textContent + '|' + t.getAttribute('aria-current')),-- hrefs: [...box.querySelectorAll('.ovsw-t')].every((t) => /^https?:/.test(t.getAttribute('href'))),-- width: document.documentElement.scrollWidth } })()`);-- let r = await read();-- assert.deepStrictEqual([r.open, r.expanded], [true, 'true']);-- assert.deepStrictEqual(r.heads, ['Recent', 'Pinned', 'Watch and stream', 'Talk and share', 'Build on OpenVibe'], 'recent, pinned, then the frame\'s groups');-- assert.deepStrictEqual(r.rows[0], ['Video', 'Live'], 'recent from the account, newest first, without the page\'s own site');-- assert.deepStrictEqual(r.rows[1], ['Design'], 'pinned from the account');-- assert.deepStrictEqual(r.rows[2], ['Live', 'Video', 'Pics'], 'a group in the frame\'s order');-- assert.deepStrictEqual(r.rows[4], ['Design', 'Mystery'], 'a site in an unknown group joins the last one');-- assert.deepStrictEqual(r.here, ['Chat|page'], 'the page\'s own site is marked by its address');-- assert.ok(r.hrefs, 'every tile is a real link');---- // Typing filters every site by name.-- await p.evaluate(`(() => { const q = document.querySelector('.ovsw-top input'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);-- await wait(50);-- r = await read();-- assert.deepStrictEqual(r.rows, [['Pics']], JSON.stringify(r));---- // Right-click pins (saved to the account), and Escape closes.-- await p.evaluate(`(() => { const q = document.querySelector('.ovsw-top input'); q.value = ''; q.dispatchEvent(new Event('input')); return true; })()`);-- await wait(50);-- await p.evaluate(`(() => { const t = [...document.querySelectorAll('.ovsw-t')].find((x) => x.textContent === 'Community'); t.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); return true; })()`);-- await wait(50);-- const puts = await p.evaluate(`window.__calls.filter((c) => c.method === 'PUT').map((c) => JSON.parse(c.body))`);-- assert.deepStrictEqual(puts, [{ pinned: ['community', 'design'] }], 'a pin goes to the account');-- r = await read();-- assert.deepStrictEqual(r.rows[1], ['Community', 'Design'], 'and shows at once');-- await p.evaluate(`document.querySelector('.ovsw-panel').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), true`);-- await wait(50);-- r = await p.evaluate(`({ open: !!document.querySelector('.ovsw.is-open'), expanded: document.getElementById('openvibe-switch-btn').getAttribute('aria-expanded'), overflow: document.documentElement.style.overflow })`);-- assert.deepStrictEqual(r, { open: false, expanded: 'false', overflow: '' }, 'Escape closes it and gives the page its scroll back');-- console.log('site-switcher: all checks passed');-- } finally {-- await browser.close();-- server.close();-- fs.rmSync(tmpDir, { recursive: true, force: true });-- }--})().catch((e) => { console.error(e); process.exit(1); });@@@ -232,7 +232,7 @@// ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),// and window 'ov:theme' { accent, accentLight, bg, mode }.// FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand-- const FAV_DEFS = '<mask id="ovh" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="#000" stroke-width="24" stroke-linecap="round"/></mask><linearGradient id="ovgo" gradientUnits="userSpaceOnUse" x1="3.09" y1="23" x2="57.09" y2="77"><stop offset="0" stop-color="#87a6ff"/><stop offset="1" stop-color="#4268d6"/></linearGradient><linearGradient id="ovgv" gradientUnits="userSpaceOnUse" x1="0" y1="31.5" x2="0" y2="68.5"><stop offset="0" stop-color="#ae8df9"/><stop offset="1" stop-color="#784fd4"/></linearGradient>';++ const FAV_DEFS = '<mask id="ovh" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="#000" stroke-width="31" stroke-linecap="round"/></mask><linearGradient id="ovgo" gradientUnits="userSpaceOnUse" x1="3.09" y1="23" x2="57.09" y2="77"><stop offset="0" stop-color="#87a6ff"/><stop offset="1" stop-color="#4268d6"/></linearGradient><linearGradient id="ovgv" gradientUnits="userSpaceOnUse" x1="0" y1="31.5" x2="0" y2="68.5"><stop offset="0" stop-color="#ae8df9"/><stop offset="1" stop-color="#784fd4"/></linearGradient>';const FAV_BODY = '<circle cx="30.09" cy="50" r="18" fill="none" stroke="url(#ovgo)" stroke-width="18" mask="url(#ovh)"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="url(#ovgv)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>';const FAV_LIGHT = { cx: 30.09, cy: 50, r: 5.58 };const FAV_SITES = { network: ['#60a5fa', ''], live: ['#60a5fa', '#ef4444'], tools: ['#38bdf8', ''], community: ['#34d399', ''], games: ['#a78bfa', '#facc15'], media: ['#fbbf24', ''], space: ['#818cf8', ''], ai: ['#22d3ee', ''] };@@@ -50,7 +50,14 @@.ovt-avatar { width: 28px; height: 28px; margin-top: 2px; border-radius: 9px;background: radial-gradient(circle at 30% 30%, color-mix(in srgb, var(--ovt-accent) 70%, #fff), var(--ovt-accent) 55%, color-mix(in srgb, var(--ovt-accent) 60%, #000));box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovt-accent) 35%, transparent); }--.ovt-turn[data-open] .ovt-avatar { animation: ovt-breathe 1.6s ease-in-out infinite; }++/* While it thinks or writes, the agent's picture shows the thinking sign (the mark's own: three dots rising in turn,++ openvibe-shared 4.187.0); a face or initials (is-who) keeps breathing instead, so it is never covered. */++.ovt-avatar { position: relative; }++.ovt-turn[data-open] .ovt-avatar.is-who { animation: ovt-breathe 1.6s ease-in-out infinite; }++.ovt-turn[data-open] .ovt-avatar:not(.is-who)::before { content: ""; position: absolute; inset: 0; border-radius: inherit;++ --d: radial-gradient(circle closest-side, #fff 88%, transparent) no-repeat;++ background: var(--d) 5px 11.5px / 5px 5px, var(--d) 11.5px 11.5px / 5px 5px, var(--d) 18px 11.5px / 5px 5px;++ animation: ovt-think 1.2s ease-in-out infinite; }.ovt-reply { min-width: 0; display: grid; gap: 10px; }/* Grid items size to their content by default: a tool row's long input would widen the page. */.ovt-reply > *, .ovt-work, .ovt-w-tool > details { min-width: 0; max-width: 100%; }@@@ -208,6 +215,12 @@ html[data-ov-motion=off] .ovt-answer.is-revealing .ovt-sw{opacity: 1 !important}.ovt-c-note:empty { display: none; }@keyframes ovt-breathe { 50% { opacity: .45; } }++@keyframes ovt-think {++ 0%, 60%, 100% { background-position: 5px 11.5px, 11.5px 11.5px, 18px 11.5px; }++ 15% { background-position: 5px 7.5px, 11.5px 11.5px, 18px 11.5px; }++ 30% { background-position: 5px 11.5px, 11.5px 7.5px, 18px 11.5px; }++ 45% { background-position: 5px 11.5px, 11.5px 11.5px, 18px 7.5px; }++}@keyframes ovt-rise { from { opacity: 0; transform: translateY(8px); } }@keyframes ovt-fade { from { opacity: 0; } }@keyframes ovt-shimmer { to { background-position: -300% 0; } }
@@ -1,5 +1,32 @@
@@ -1,5 +1,32 @@
# Changelog
# Changelog
+## 4.187.0 — 2026-11-10
+
+**The bar the owner chose.** 2026-11-10, in the Arena: "here's the new menu button and the logo … these two, but
+improve upon and expand on it. And let's move the user … so that it's to the right of this. And instead of having
+messaging and notifications, just have the notifications."
+
+- **The menu button is "Search and apps"** (the Arena's M57): the menu itself, small: a search bar over three apps.
+ Under the pointer its parts lift in turn; while the menu loads the apps breathe; open, the bar turns into one stroke
+ of the close mark and the three apps line up as the other. One drawing (`OpenVibeOrb.svg()`, navbar.js) for the
+ bar, the menu's sheet and the Dock's sheet, where three copies of nine dots were.
+- **The mark has the wide gap** (the Arena's P07): the ground around the V where it crosses the O is 14, was 7, in
+ `ov-mark.js`, `mark.js`, the app icons and the loader's tab icon alike (`test/mark.test.js`).
+- **The person is last in the bar**: balances, the bell, the menu button, then the picture (or Sign In).
+- **Messages is the bell.** The Messages button leaves the bar; the count of new messages (Chat's, or a site's own
+ through `setMessages(count)`) is added to the bell's number and named in its label ("Notifications, 1 unread, 2 new
+ messages"). With new messages and nothing else new, the bell opens the Dock on Chats; otherwise on Activity.
+- **One menu.** The owner: "make sure these two share the same universal system … it just opens the actual first
+ tab … same UI." The mark opens the OpenVibe menu on its first tab; `site-switcher.js`, its panel, its own icons
+ and its "here" label are deleted. `OpenVibeNavbar.openSwitcher()` opens the menu.
+- **All apps is a small button beside Edit** in the menu's You tab ("All 65"), where a bar the width of the sheet
+ was under the grid.
+- **An agent's turn shows it is thinking**: in the conversation kit (`thread.css`), the agent's avatar shows three
+ rising dots while its turn is open.
+
+Removed: `site-switcher.js` (and its export), the Messages button and its `messages: { href, onOpen, label }`
+behaviours (the Dock's Chats tab is where messages open; `messages: false` still turns the count off).
+
## 4.186.0 — 2026-11-10
## 4.186.0 — 2026-11-10
**The mark at work is a ring of dots, and thinking is a state of its own.** The owner, 2026-11-10: "the progress
**The mark at work is a ring of dots, and thinking is a state of its own.** The owner, 2026-11-10: "the progress
@@ -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.186.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.187.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.
@@ -827,11 +827,13 @@ Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,
@@ -827,11 +827,13 @@ Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,
bots and unavailable sessionStorage suppress the attempt. The site's login
bots and unavailable sessionStorage suppress the attempt. The site's login
handler must return silent failures without showing a login form (openvibe-sdk/sso).
handler must return silent failures without showing a login form (openvibe-sdk/sso).
OpenVibeSSO.fedcm remains available for browser-native sign-in flows.
OpenVibeSSO.fedcm remains available for browser-native sign-in flows.
-Site switcher (4.48): the OpenVibe mark is a button that opens
- site-switcher.js, a compact grid of every OpenVibe site with Recent, Pinned,
- then the frame's groups. Recent and pinned follow the account (Network
- /api/themes/me/sites) when signed in. The site's name stays the link home;
- switcher: false keeps the mark as a home link.
+One menu (4.187): the OpenVibe mark is a button that opens the OpenVibe menu
+ on its first tab, as the menu button does (there is no separate site
+ switcher). The site's name stays the link home; switcher: false keeps the
+ mark as a home link.
+The bar's order (4.187): balances, the bell, the menu button, then the
+ person's picture (or Sign In), last. New messages are counted on the bell
+ (setMessages(count)); there is no Messages button.
Balances: chips: [{ id, icon, value, label, tone, title, onClick, hidden }]
Balances: chips: [{ id, icon, value, label, tone, title, onClick, hidden }]
are status chips beside the bell (Live's OpenCoins and Vibes); setChip(id,
are status chips beside the bell (Live's OpenCoins and Vibes); setChip(id,
value) updates one. Two or more fold into one chip at 560 px and narrower.
value) updates one. Two or more fold into one chip at 560 px and narrower.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.186.0",
+ "version": "4.187.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -34,7 +34,8 @@
@@ -34,7 +34,8 @@
const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
// Close is the OpenVibe menu's orb in its × form (11-09, the owner: "replace this close button with our own… the
// Close is the OpenVibe menu's orb in its × form (11-09, the owner: "replace this close button with our own… the
// corner button… its close form… nice animations"): nine dots that fold into an × as the Dock opens.
// corner button… its close form… nice animations"): nine dots that fold into an × as the Dock opens.
- const ORBX = `<svg viewBox="0 0 24 24" aria-hidden="true"><g class="ovdk-ox">${[[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]].map(([x, y, r]) => `<circle class="ovdk-o${r}" cx="${x}" cy="${y}" r="${r ? 2.15 : 2.5}"/>`).join('')}</g></svg>`;
+ // The close button is the menu button, already crossed (navbar.js, root.OpenVibeOrb): it forms as the sheet opens.
+ const ORBX = () => (root.OpenVibeOrb ? root.OpenVibeOrb.svg(22, true) : '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>');
const G = {
const G = {
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
@@ -182,17 +183,11 @@
@@ -182,17 +183,11 @@
.ovdk-acts button:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
.ovdk-acts button:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
.ovdk-acts button svg { width: 19px; height: 19px; display: block; }
.ovdk-acts button svg { width: 19px; height: 19px; display: block; }
.ovdk-orbx svg { width: 22px !important; height: 22px !important; overflow: visible; }
.ovdk-orbx svg { width: 22px !important; height: 22px !important; overflow: visible; }
- .ovdk-ox { transform-origin: 12px 12px; transform: rotate(180deg) scale(.86); transition: transform .45s cubic-bezier(.2, 1.35, .3, 1); }
- .ovdk-ox circle { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: opacity .25s, transform .3s cubic-bezier(.2, 1.4, .3, 1), fill .2s; }
- .ovdk-ox .ovdk-o1 { opacity: 0; transform: scale(0); }
- .ovdk-ox .ovdk-o0 { fill: var(--accent, #3b82f6); }
- .ovdk.is-in .ovdk-ox { animation: ovdk-ox-in .55s cubic-bezier(.2, 1.35, .3, 1) both; }
- .ovdk.is-in .ovdk-ox .ovdk-o1 { animation: ovdk-o1-out .4s ease both; }
- .ovdk-orbx:hover .ovdk-ox, .ovdk-orbx:focus-visible .ovdk-ox { transform: rotate(270deg) scale(.9); }
- .ovdk-orbx:hover .ovdk-ox circle { fill: var(--accent-light, #60a5fa); }
- .ovdk-orbx:active .ovdk-ox { transform: rotate(270deg) scale(.7); }
- @keyframes ovdk-ox-in { from { transform: rotate(45deg) scale(.8); } }
- @keyframes ovdk-o1-out { from { opacity: 1; transform: scale(1); } }
+ .ovdk-orbx { --ovo-a: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovdk.is-in .ovdk-orbx .ovmb :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }
+ .ovdk-orbx:hover .ovmb, .ovdk-orbx:focus-visible .ovmb { transform: rotate(90deg); }
+ .ovdk-orbx .ovmb { transition: transform .35s cubic-bezier(.2,1.3,.3,1); }
+ .ovdk-orbx:active .ovmb { transform: rotate(90deg) scale(.8); }
/* A phone's sheet opens out of the navbar's orb as the menu's does (4.186.0; see CHANGELOG): one move, one close button. */
/* A phone's sheet opens out of the navbar's orb as the menu's does (4.186.0; see CHANGELOG): one move, one close button. */
.ovdk.from-orb.is-in { animation: ovdk-reveal .38s cubic-bezier(.2,.9,.25,1) both; }
.ovdk.from-orb.is-in { animation: ovdk-reveal .38s cubic-bezier(.2,.9,.25,1) both; }
.ovdk.from-orb.is-out { animation: ovdk-fold .28s cubic-bezier(.5,0,.75,.2) both; pointer-events: none; }
.ovdk.from-orb.is-out { animation: ovdk-fold .28s cubic-bezier(.5,0,.75,.2) both; pointer-events: none; }
@@ -204,23 +199,11 @@
@@ -204,23 +199,11 @@
.from-orb .ovdk-acts .ovdk-orbx.is-from { background: transparent; box-shadow: none; }
.from-orb .ovdk-acts .ovdk-orbx.is-from { background: transparent; box-shadow: none; }
.from-orb.is-in .ovdk-orbx::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--accent-light, #60a5fa); pointer-events: none; animation: ovdk-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
.from-orb.is-in .ovdk-orbx::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--accent-light, #60a5fa); pointer-events: none; animation: ovdk-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
@keyframes ovdk-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
@keyframes ovdk-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
- .from-orb.is-in .ovdk-ox, .from-orb.is-in .ovdk-o1 { animation: none !important; }
.from-orb .ovdk-orbx { color: var(--text-primary, #ececf1); }
.from-orb .ovdk-orbx { color: var(--text-primary, #ececf1); }
.from-orb .ovdk-orbx svg { transition: transform .42s cubic-bezier(.2, 1.1, .3, 1); }
.from-orb .ovdk-orbx svg { transition: transform .42s cubic-bezier(.2, 1.1, .3, 1); }
- .from-orb .ovdk-ox circle { transition: fill .3s, opacity .3s, transform .35s cubic-bezier(.2, 1.4, .3, 1); }
- .from-orb .ovdk-ox { transform: rotate(180deg) scale(.95); }
- .from-orb .ovdk-ox :is(.ovdk-o0, .ovdk-o2) { fill: var(--accent-light, #60a5fa); transform: scale(1.12); }
- .from-orb .is-from .ovdk-ox .ovdk-o0 { fill: var(--accent-light, #60a5fa); }
.ovdk-orbx.is-set, .ovdk-orbx.is-set * { transition: none !important; }
.ovdk-orbx.is-set, .ovdk-orbx.is-set * { transition: none !important; }
.from-orb .is-from svg { transform: translate(var(--orb-dx, 0), var(--orb-dy, 0)) scale(var(--orb-s, 1)); }
.from-orb .is-from svg { transform: translate(var(--orb-dx, 0), var(--orb-dy, 0)) scale(var(--orb-s, 1)); }
- .from-orb .is-from .ovdk-ox { transform: rotate(45deg) scale(.8); }
- .from-orb .is-from .ovdk-ox circle { fill: var(--text-secondary, #a8b3c4); opacity: 1; transform: none; }
- .from-orb .is-from .ovdk-ox .ovdk-o2 { opacity: .6; }
.ovnav-orb.is-dock { transform: translate(var(--dock-dx, 0), var(--dock-dy, 0)) scale(var(--dock-s, 1)) !important; color: var(--text-primary, #e6edf7) !important; }
.ovnav-orb.is-dock { transform: translate(var(--dock-dx, 0), var(--dock-dy, 0)) scale(var(--dock-s, 1)) !important; color: var(--text-primary, #e6edf7) !important; }
- .ovnav-orb.is-dock .ovo-g { transform: rotate(180deg) scale(.86); transition-duration: .45s; }
- .ovnav-orb.is-dock .ovo-d { animation: none; }
- .ovnav-orb.is-dock .ovo-r1 { opacity: 0; transform: scale(0); }
- .ovnav-orb.is-dock :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; }
.ovdk-more { position: absolute; z-index: 6; right: 8px; top: calc(100% - 4px); width: min(320px, calc(100% - 16px)); display: grid; gap: 2px; padding: 6px; border-radius: 16px;
.ovdk-more { position: absolute; z-index: 6; right: 8px; top: calc(100% - 4px); width: min(320px, calc(100% - 16px)); display: grid; gap: 2px; padding: 6px; border-radius: 16px;
border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); box-shadow: 0 18px 40px -12px rgba(0,0,0,.55); cursor: default; }
border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); box-shadow: 0 18px 40px -12px rgba(0,0,0,.55); cursor: default; }
.ovdk-more[hidden] { display: none; }
.ovdk-more[hidden] { display: none; }
@@ -366,7 +349,7 @@
@@ -366,7 +349,7 @@
<div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div>
<div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div>
<div class="ovdk-acts">
<div class="ovdk-acts">
<button type="button" data-act="more" aria-haspopup="true" aria-expanded="false" title="More" aria-label="More: a timer, the Dock's button">${svg(G.more)}</button>
<button type="button" data-act="more" aria-haspopup="true" aria-expanded="false" title="More" aria-label="More: a timer, the Dock's button">${svg(G.more)}</button>
- <button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX}</button>
+ <button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX()}</button>
</div>
</div>
<div class="ovdk-more" role="menu" aria-label="More" hidden>
<div class="ovdk-more" role="menu" aria-label="More" hidden>
<button type="button" role="menuitem" data-act="timer">${svg(G.clock)}<span><b>Start a timer</b><small>A countdown or a focus round, on the Dock's button</small></span></button>
<button type="button" role="menuitem" data-act="timer">${svg(G.clock)}<span><b>Start a timer</b><small>A countdown or a focus round, on the Dock's button</small></span></button>
@@ -35,7 +35,6 @@ const BROWSER = Object.freeze([
@@ -35,7 +35,6 @@ const BROWSER = Object.freeze([
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'account-switcher.js',
'account-switcher.js',
- 'site-switcher.js', // the OpenVibe mark's site switcher (4.48.0); navbar.js loads it on the first tap. Missing from this list until 4.106.1, so every site answered 404 for it and the mark did nothing
'user-card.js',
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
'ov-mark.js', // the OV brand mark drop-in
'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)
'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)
@@ -30,6 +30,9 @@ const COLORS = Object.freeze({ o: '#4f7cff', v: '#8b5cf6' });
@@ -30,6 +30,9 @@ const COLORS = Object.freeze({ o: '#4f7cff', v: '#8b5cf6' });
const f = (v) => +v.toFixed(2);
const f = (v) => +v.toFixed(2);
/** The Pair: ring radius r and stroke sw, the V's stroke vsw; the V's left cap a, tip t and right cap b, as multiples of the ring's outer radius from its centre. */
/** The Pair: ring radius r and stroke sw, the V's stroke vsw; the V's left cap a, tip t and right cap b, as multiples of the ring's outer radius from its centre. */
+// The ground around the V where it crosses the O: the wide gap (the owner's pick, 2026-11-10; it was 7). ov-mark.js
+// has the same number (test/mark.test.js).
+const HALO = 14;
function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;
const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;
const A = [f(cx + a * R), f(cy - R + vsw / 2)], T = [f(cx + t * R), f(cy + R - vsw / 2)], B = [f(cx + b * R), f(cy - R + vsw / 2)];
const A = [f(cx + a * R), f(cy - R + vsw / 2)], T = [f(cx + t * R), f(cy + R - vsw / 2)], B = [f(cx + b * R), f(cy - R + vsw / 2)];
@@ -38,7 +41,7 @@ function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
@@ -38,7 +41,7 @@ function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
const x0 = f(cx - R), x1 = f(B[0] + vsw / 2), pad = 2;
const x0 = f(cx - R), x1 = f(B[0] + vsw / 2), pad = 2;
const w = f(x1 - x0 + 2 * pad);
const w = f(x1 - x0 + 2 * pad);
return {
return {
- cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + 7, A, T, B, ring,
+ cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + HALO, A, T, B, ring,
// A square box centred on the mark, and a wide one that hugs it.
// A square box centred on the mark, and a wide one that hugs it.
box: `${f(50 - w / 2)} ${f(50 - w / 2)} ${w} ${w}`,
box: `${f(50 - w / 2)} ${f(50 - w / 2)} ${w} ${w}`,
wideBox: `${f(50 - w / 2)} ${f(50 - R - pad)} ${w} ${f(2 * R + 2 * pad)}`,
wideBox: `${f(50 - w / 2)} ${f(50 - R - pad)} ${w} ${f(2 * R + 2 * pad)}`,
@@ -289,33 +289,44 @@
@@ -289,33 +289,44 @@
.openvibe-navbar-spacer { flex: 1; }
.openvibe-navbar-spacer { flex: 1; }
.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
- /* The OpenVibe menu's button (4.25.0; the owner, 2026-10-28: "the little diamond thing… an icon that has cool
- animations and effects… next to the logo"): nine dots set as a diamond beside the brand. Hovered, a ripple
- runs out from the centre and the dots take the accent's colours; while the menu loads they wave; while it is
- open the diamond turns into an ×. Transform, opacity and fill only, and nothing moves while it rests. */
+ /* The person (or Sign In) is last, after the menu button, with a little air between them. */
+ .openvibe-navbar .ovnav-burger + :is(.openvibe-navbar-avatar, .openvibe-navbar-login) { margin-left: 3px; }
+ /* The OpenVibe menu's button (4.187.0; the owner's pick of 2026-11-10: a search bar over three apps). Under
+ the pointer its parts lift in turn; while the menu loads the apps breathe; open, the bar and the apps cross
+ into the close mark. Transform, opacity and fill only, and nothing moves while it rests. */
.openvibe-navbar .ovnav-orb { all: unset; box-sizing: border-box; position: relative; display: inline-grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 11px; cursor: pointer; color: var(--text-secondary, #a8b3c4); --ovo-a: var(--accent-light, var(--accent, #60a5fa)); --ovo-b: color-mix(in oklab, var(--accent, #3b82f6) 45%, #e879f9); }
.openvibe-navbar .ovnav-orb { all: unset; box-sizing: border-box; position: relative; display: inline-grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 11px; cursor: pointer; color: var(--text-secondary, #a8b3c4); --ovo-a: var(--accent-light, var(--accent, #60a5fa)); --ovo-b: color-mix(in oklab, var(--accent, #3b82f6) 45%, #e879f9); }
.openvibe-navbar .ovnav-orb::before { content: ""; position: absolute; inset: -3px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--accent, #3b82f6) 38%, transparent), transparent); opacity: 0; transform: scale(.5); transition: opacity .25s, transform .4s cubic-bezier(.2,1.4,.3,1); pointer-events: none; }
.openvibe-navbar .ovnav-orb::before { content: ""; position: absolute; inset: -3px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--accent, #3b82f6) 38%, transparent), transparent); opacity: 0; transform: scale(.5); transition: opacity .25s, transform .4s cubic-bezier(.2,1.4,.3,1); pointer-events: none; }
.openvibe-navbar .ovnav-orb:hover::before, .openvibe-navbar .ovnav-orb:focus-visible::before, .openvibe-navbar .ovnav-orb[aria-expanded="true"]::before { opacity: 1; transform: none; }
.openvibe-navbar .ovnav-orb:hover::before, .openvibe-navbar .ovnav-orb:focus-visible::before, .openvibe-navbar .ovnav-orb[aria-expanded="true"]::before { opacity: 1; transform: none; }
.openvibe-navbar .ovnav-orb svg { position: relative; display: block; overflow: visible; }
.openvibe-navbar .ovnav-orb svg { position: relative; display: block; overflow: visible; }
- .ovnav-orb .ovo-g { transform-origin: 12px 12px; transform: rotate(45deg) scale(.8); transition: transform .55s cubic-bezier(.2,1.35,.3,1); }
- .ovnav-orb .ovo-d { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: fill .3s, opacity .3s, transform .35s cubic-bezier(.2,1.4,.3,1); }
- .ovnav-orb .ovo-r2 { opacity: .6; }
- .ovnav-orb .ovo-r0 { fill: var(--ovo-a); }
- .ovnav-orb:is(:hover, :focus-visible) .ovo-r1 { fill: var(--ovo-a); }
- .ovnav-orb:is(:hover, :focus-visible) .ovo-r2 { fill: var(--ovo-b); opacity: 1; }
- .ovnav-orb:is(:hover, :focus-visible) .ovo-r0 { fill: var(--text-primary, #e6edf7); }
- .ovnav-orb:is(:hover, .ovo-intro) .ovo-d { animation: ovo-ripple .7s cubic-bezier(.3,.7,.3,1) both; }
- .ovnav-orb.is-busy .ovo-d { animation: ovo-wave .9s ease-in-out infinite; }
- .ovnav-orb .ovo-d.ovo-r1 { animation-delay: .07s; } .ovnav-orb .ovo-d.ovo-r2 { animation-delay: .14s; }
+ .ovmb { display: block; overflow: visible; }
+ .ovmb-bar { fill: transparent; stroke: currentColor; stroke-width: 1.9; }
+ .ovmb-app { fill: currentColor; }
+ .ovmb-dot, .ovmb-a1 { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
+ .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { transform-box: fill-box; transform-origin: center; transition: transform .5s cubic-bezier(.2,1.3,.3,1), fill .3s, stroke-width .3s, opacity .25s; }
+ .ovmb-a0 { transition-delay: .03s; } .ovmb-a1 { transition-delay: .06s; } .ovmb-a2 { transition-delay: .09s; }
+ /* Under the pointer (and once, the first time it is seen): the bar and the three apps lift in turn. */
+ :is(.ovnav-orb, .ovs-orb):is(:hover, .ovo-intro) .ovmb:not(.is-x) :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: ovmb-wave .7s ease-in-out both; }
+ .ovmb .ovmb-a0 { animation-delay: .08s; } .ovmb .ovmb-a1 { animation-delay: .15s; } .ovmb .ovmb-a2 { animation-delay: .22s; }
+ :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
+ :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-a1 { fill: var(--ovo-b, #e879f9); }
+ /* While the menu loads: the apps breathe in turn. */
+ .ovnav-orb.is-busy .ovmb-app { animation: ovmb-busy .9s ease-in-out infinite; }
+ /* Open: the bar is one stroke of the close mark, the three apps the other. */
+ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app), .ovmb.is-x :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: none; }
+ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-bar, .ovmb.is-x .ovmb-bar { transform: translateY(4px) rotate(45deg) scale(.9, .43); fill: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }
+ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-dot, .ovmb.is-x .ovmb-dot { transform: translate(4.6px, 4px) scale(0); opacity: 0; }
+ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-app, .ovmb.is-x .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
+ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a0, .ovmb.is-x .ovmb-a0 { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.3, .6); }
+ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a1, .ovmb.is-x .ovmb-a1 { transform: translate(0, -4.6px) rotate(-45deg) scale(1.3, .6); }
+ :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a2, .ovmb.is-x .ovmb-a2 { transform: translate(-2.4px, -8px) rotate(-45deg) scale(1.3, .6); }
+ /* A close mark that arrives already made (the Dock's sheet) forms from the button it came from. */
+ .ovmb.is-forming :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }
+ @keyframes ovmb-form { from { transform: none; } }
+ @keyframes ovmb-wave { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(calc(-2.6px * var(--ov-move, 1))); } }
+ @keyframes ovmb-busy { 0%, 100% { transform: scale(.72); opacity: .5; } 50% { transform: scale(1.18); opacity: 1; } }
.openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); }
.openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); }
- .ovnav-orb[aria-expanded="true"] .ovo-g { transform: rotate(180deg) scale(.86); }
- .ovnav-orb[aria-expanded="true"] .ovo-d { animation: none; }
- .openvibe-navbar :is(.ovnav-orb, .ovnav-msgs, .openvibe-bell, .openvibe-navbar-avatar) { transition: transform .18s cubic-bezier(.2, 1.4, .3, 1), background-color .15s, color .15s; }
- .openvibe-navbar :is(.ovnav-orb, .ovnav-msgs, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); }
- .ovnav-orb[aria-expanded="true"] .ovo-r1 { opacity: 0; transform: scale(0); }
- .ovnav-orb[aria-expanded="true"] :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; }
- @keyframes ovo-ripple { 0%, 100% { transform: scale(1); } 45% { transform: scale(1.55); } }
- @keyframes ovo-wave { 0%, 100% { transform: scale(.7); opacity: .5; } 50% { transform: scale(1.25); opacity: 1; } }
+ .openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar) { transition: transform .18s cubic-bezier(.2, 1.4, .3, 1), background-color .15s, color .15s; }
+ .openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); }
.openvibe-navbar .openvibe-navbar-brand .ovnav-orb { margin-left: 1px; }
.openvibe-navbar .openvibe-navbar-brand .ovnav-orb { margin-left: 1px; }
/* 4.148.0 (the owner: "replace the hamburger with that button"): the phone's menu button is the orb, in the
/* 4.148.0 (the owner: "replace the hamburger with that button"): the phone's menu button is the orb, in the
burger's place at the bar's right. Hidden on a wide bar (its links are there), shown at 860 px and below;
burger's place at the bar's right. Hidden on a wide bar (its links are there), shown at 860 px and below;
@@ -329,18 +340,12 @@
@@ -329,18 +340,12 @@
/* Messages (4.25.0; the owner, 2026-10-28: the floating DM button was "buggy as hell on mobile… app style"): a
/* Messages (4.25.0; the owner, 2026-10-28: the floating DM button was "buggy as hell on mobile… app style"): a
site that has messages puts them in the bar beside the bell, with the unread count, instead of a bubble
site that has messages puts them in the bar beside the bell, with the unread count, instead of a bubble
over the page. */
over the page. */
- .openvibe-navbar .ovnav-msgs { all: unset; box-sizing: border-box; position: relative; display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 11px; cursor: pointer; color: var(--text-secondary, #a8b3c4); }
- .openvibe-navbar .ovnav-msgs:hover, .openvibe-navbar .ovnav-msgs:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--text-primary, #e6edf7); }
- .openvibe-navbar .ovnav-msgs .ovnav-ic { width: 22px; height: 22px; }
.ovnav-ovg { width: 1.15em; height: 1.15em; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; overflow: visible; vertical-align: -.2em; }
.ovnav-ovg { width: 1.15em; height: 1.15em; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; overflow: visible; vertical-align: -.2em; }
.ovnav-chip .ovnav-ovg, .ovnav-chip-mini .ovnav-ovg, .ovnav-chips-pop .ovnav-ovg { stroke-width: 2.3; }
.ovnav-chip .ovnav-ovg, .ovnav-chip-mini .ovnav-ovg, .ovnav-chips-pop .ovnav-ovg { stroke-width: 2.3; }
.ovnav-ovg > .ovg-s { display: none; fill: currentColor; stroke: none; }
.ovnav-ovg > .ovg-s { display: none; fill: currentColor; stroke: none; }
.ovnav-ovg > .ovg-s .ln { fill: none; stroke: currentColor; stroke-width: 2.6px; }
.ovnav-ovg > .ovg-s .ln { fill: none; stroke: currentColor; stroke-width: 2.6px; }
html[data-ov-icons=solid] .ovnav-ovg.has-s, .ovnav-ab.is-here .ovnav-ovg.has-s { stroke-width: 0; }
html[data-ov-icons=solid] .ovnav-ovg.has-s, .ovnav-ab.is-here .ovnav-ovg.has-s { stroke-width: 0; }
html[data-ov-icons=solid] .ovnav-ovg.has-s > .ovg-s, .ovnav-ab.is-here .ovnav-ovg.has-s > .ovg-s { display: inline; }
html[data-ov-icons=solid] .ovnav-ovg.has-s > .ovg-s, .ovnav-ab.is-here .ovnav-ovg.has-s > .ovg-s { display: inline; }
- .openvibe-navbar .ovnav-msgs b { position: absolute; top: -1px; right: -3px; min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--live-red, #ef4444); color: #fff; font: 700 10px/16px Inter, system-ui, sans-serif; text-align: center; box-shadow: 0 0 0 2px var(--bg-secondary, #111826); }
- .openvibe-navbar .ovnav-msgs b:empty { display: none; }
- @media (max-width: 400px) { .openvibe-navbar .ovnav-msgs { width: 32px; height: 32px; } }
.openvibe-navbar-avatar {
.openvibe-navbar-avatar {
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
@@ -363,12 +368,12 @@
@@ -363,12 +368,12 @@
Sign In beside it; Sign In's corners follow the orb's. */
Sign In beside it; Sign In's corners follow the orb's. */
.openvibe-navbar .ovnav-orb { width: 36px; height: 36px; }
.openvibe-navbar .ovnav-orb { width: 36px; height: 36px; }
.openvibe-navbar .ovnav-orb svg { width: 26px; height: 26px; }
.openvibe-navbar .ovnav-orb svg { width: 26px; height: 26px; }
- .openvibe-navbar .ovnav-msgs, .openvibe-navbar .ovnav-featured-phone { width: 36px; height: 36px; }
+ .openvibe-navbar .ovnav-featured-phone { width: 36px; height: 36px; }
.openvibe-navbar .openvibe-navbar-avatar { width: 36px; height: 36px; box-sizing: border-box; }
.openvibe-navbar .openvibe-navbar-avatar { width: 36px; height: 36px; box-sizing: border-box; }
.openvibe-navbar .ovnav-chip { height: 36px; }
.openvibe-navbar .ovnav-chip { height: 36px; }
.openvibe-navbar .openvibe-navbar-login { box-sizing: border-box; height: 36px; padding: 0 16px; font-size: 14px; border-radius: 10px; }
.openvibe-navbar .openvibe-navbar-login { box-sizing: border-box; height: 36px; padding: 0 16px; font-size: 14px; border-radius: 10px; }
@media (max-width: 400px) {
@media (max-width: 400px) {
- .openvibe-navbar .ovnav-orb, .openvibe-navbar .ovnav-msgs, .openvibe-navbar .ovnav-featured-phone, .openvibe-navbar .openvibe-navbar-avatar { width: 34px; height: 34px; }
+ .openvibe-navbar .ovnav-orb, .openvibe-navbar .ovnav-featured-phone, .openvibe-navbar .openvibe-navbar-avatar { width: 34px; height: 34px; }
.openvibe-navbar .ovnav-orb svg { width: 24px; height: 24px; }
.openvibe-navbar .ovnav-orb svg { width: 24px; height: 24px; }
.openvibe-navbar .ovnav-chip, .openvibe-navbar .openvibe-navbar-login { height: 34px; }
.openvibe-navbar .ovnav-chip, .openvibe-navbar .openvibe-navbar-login { height: 34px; }
.openvibe-navbar .openvibe-navbar-login { padding: 0 13px; }
.openvibe-navbar .openvibe-navbar-login { padding: 0 13px; }
@@ -557,17 +562,16 @@
@@ -557,17 +562,16 @@
'fa-circle-half-stroke': [0, '<circle cx="12" cy="12" r="9"/><path d="M12 3v18M12 7.5h7M12 12h9M12 16.5h7"/>', '<path fill-rule="evenodd" d="M12 2.4a9.6 9.6 0 1 0 0 19.2 9.6 9.6 0 0 0 0-19.2Zm0 2.2a7.4 7.4 0 0 1 0 14.8Z"/>'],
'fa-circle-half-stroke': [0, '<circle cx="12" cy="12" r="9"/><path d="M12 3v18M12 7.5h7M12 12h9M12 16.5h7"/>', '<path fill-rule="evenodd" d="M12 2.4a9.6 9.6 0 1 0 0 19.2 9.6 9.6 0 0 0 0-19.2Zm0 2.2a7.4 7.4 0 0 1 0 14.8Z"/>'],
'fa-moon': [0, '<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z"/>', '<path d="M20.4 14.4A8.5 8.5 0 1 1 9.6 3.6a7 7 0 0 0 10.8 10.8Z"/>'],
'fa-moon': [0, '<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z"/>', '<path d="M20.4 14.4A8.5 8.5 0 1 1 9.6 3.6a7 7 0 0 0 10.8 10.8Z"/>'],
'fa-sun': [0, '<circle cx="12" cy="12" r="3.8"/><path d="M12 2.5v2M12 19.6v2M5.25 5.25l1.4 1.4M17.4 17.4l1.4 1.4M2.5 12h2M19.6 12h2M5.25 18.75l1.4 -1.4M17.4 6.6l1.4 -1.4"/>', '<circle cx="12" cy="12" r="4.6"/><path class="ln" d="M12 2.2v1.6M12 20.2v1.6M5 5l1.1 1.1M17.9 17.9 19 19M2.2 12h1.6M20.2 12h1.6M5 19l1.1-1.1M17.9 6.1 19 5"/>'],
'fa-sun': [0, '<circle cx="12" cy="12" r="3.8"/><path d="M12 2.5v2M12 19.6v2M5.25 5.25l1.4 1.4M17.4 17.4l1.4 1.4M2.5 12h2M19.6 12h2M5.25 18.75l1.4 -1.4M17.4 6.6l1.4 -1.4"/>', '<circle cx="12" cy="12" r="4.6"/><path class="ln" d="M12 2.2v1.6M12 20.2v1.6M5 5l1.1 1.1M17.9 17.9 19 19M2.2 12h1.6M20.2 12h1.6M5 19l1.1-1.1M17.9 6.1 19 5"/>'],
- 'fa-comment-dots': [0, '<path d="M4 5h16v11H8l-4 4Z"/>', '<path d="M3.5 5.6A1.6 1.6 0 0 1 5.1 4h13.8a1.6 1.6 0 0 1 1.6 1.6v9.8a1.6 1.6 0 0 1-1.6 1.6H8.6L3.5 21Z"/>'],
'fa-brain': [0, '<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1.2V5.5A2 2 0 0 0 9 4ZM15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1.2"/>', '<path d="M9 3.8a3.1 3.1 0 0 0-3.1 3 3 3 0 0 0-2.1 5.2 3 3 0 0 0 2.1 5.1 3.1 3.1 0 0 0 5.5 1.3V5.5A2.2 2.2 0 0 0 9 3.8Z"/><path d="M15 3.8a3.1 3.1 0 0 1 3.1 3 3 3 0 0 1 2.1 5.2 3 3 0 0 1-2.1 5.1 3.1 3.1 0 0 1-5.5 1.3V5.5A2.2 2.2 0 0 1 15 3.8Z"/>'],
'fa-brain': [0, '<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1.2V5.5A2 2 0 0 0 9 4ZM15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1.2"/>', '<path d="M9 3.8a3.1 3.1 0 0 0-3.1 3 3 3 0 0 0-2.1 5.2 3 3 0 0 0 2.1 5.1 3.1 3.1 0 0 0 5.5 1.3V5.5A2.2 2.2 0 0 0 9 3.8Z"/><path d="M15 3.8a3.1 3.1 0 0 1 3.1 3 3 3 0 0 1 2.1 5.2 3 3 0 0 1-2.1 5.1 3.1 3.1 0 0 1-5.5 1.3V5.5A2.2 2.2 0 0 1 15 3.8Z"/>'],
};
};
// Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives); 0 is an OpenVibe glyph.
// Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives); 0 is an OpenVibe glyph.
const NAV_ICONS_LAZY = {
const NAV_ICONS_LAZY = {
- 0: 'fa-book fa-camera fa-comments fa-gamepad fa-gauge-high fa-hand-fist fa-house fa-link fa-list-check fa-network-wired fa-paste fa-people-arrows fa-scissors fa-screwdriver-wrench fa-shirt fa-text-height fa-user fa-video fa-wand-magic-sparkles fa-arrow-up-right-from-square fa-chevron-right fa-bars fa-xmark fa-magnifying-glass fa-file-pdf fa-arrow-down fa-arrow-right fa-clock fa-envelope fa-star fa-heart fa-play fa-circle-info fa-triangle-exclamation fa-check fa-globe fa-code fa-robot fa-photo-film fa-tower-cell fa-diagram-project fa-toolbox fa-money-bill-wave fa-crown fa-server fa-newspaper fa-tag fa-ticket fa-chart-line fa-pen-nib fa-key fa-plug fa-bolt fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music fa-file-lines fa-font fa-location-dot fa-lock fa-wave-square fa-map fa-utensils fa-user-shield fa-gauge fa-pen fa-earth-americas fa-hashtag fa-inbox fa-folder fa-puzzle-piece fa-shield fa-display fa-microphone fa-infinity fa-list fa-flag-checkered fa-tv fa-compass',
+ 0: 'fa-book fa-camera fa-comments fa-gamepad fa-gauge-high fa-hand-fist fa-house fa-link fa-list-check fa-network-wired fa-paste fa-people-arrows fa-scissors fa-screwdriver-wrench fa-shirt fa-text-height fa-user fa-video fa-wand-magic-sparkles fa-arrow-up-right-from-square fa-chevron-right fa-bars fa-xmark fa-magnifying-glass fa-file-pdf fa-arrow-down fa-arrow-right fa-clock fa-envelope fa-star fa-heart fa-play fa-circle-info fa-triangle-exclamation fa-check fa-globe fa-code fa-robot fa-photo-film fa-tower-cell fa-diagram-project fa-toolbox fa-comment-dots fa-money-bill-wave fa-crown fa-server fa-newspaper fa-tag fa-ticket fa-chart-line fa-pen-nib fa-key fa-plug fa-bolt fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music fa-file-lines fa-font fa-location-dot fa-lock fa-wave-square fa-map fa-utensils fa-user-shield fa-gauge fa-pen fa-earth-americas fa-hashtag fa-inbox fa-folder fa-puzzle-piece fa-shield fa-display fa-microphone fa-infinity fa-list fa-flag-checkered fa-tv fa-compass',
};
};
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': '4fb7af77a431', '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': 'c6dbd7d46ae7', 'dock.js': '2c59b0f0e035', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'dc24929109b5', 'history.js': '1e6598f78ba4', 'site-switcher.js': 'd8980cf95157', 'appbar.js': '4e34b7d60372', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '6252acb43bd4', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
+ const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'bb502f67c4d5', 'dock.js': '00e20b4b2895', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': '1e6598f78ba4', 'appbar.js': '4e34b7d60372', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'db1d79b7b932', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
// 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">.
@@ -927,7 +931,7 @@
@@ -927,7 +931,7 @@
return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}">
return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}">
${_config.switcher === false
${_config.switcher === false
? `<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>`
? `<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>`
- : `<button type="button" class="flame" id="openvibe-switch-btn" aria-haspopup="dialog" aria-expanded="false" aria-label="Switch to another OpenVibe site" title="Every OpenVibe site">${mark}</button>`}
+ : `<button type="button" class="flame" id="openvibe-switch-btn" aria-haspopup="dialog" aria-expanded="false" aria-label="OpenVibe menu: every site and tool" title="Every OpenVibe site">${mark}</button>`}
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
${_config.search === false ? '' : orbHTML()}
${_config.search === false ? '' : orbHTML()}
</div>`;
</div>`;
@@ -981,13 +985,16 @@
@@ -981,13 +985,16 @@
try { const sc = document.createElement('script'); sc.id = 'ov-slots-loader'; sc.src = siblingV('nav-slots.js'); sc.async = true; sc.onload = () => paintSlots(); document.head.appendChild(sc); } catch { /* */ }
try { const sc = document.createElement('script'); sc.id = 'ov-slots-loader'; sc.src = siblingV('nav-slots.js'); sc.async = true; sc.onload = () => paintSlots(); document.head.appendChild(sc); } catch { /* */ }
}
}
- // The OpenVibe menu's button: nine dots as a diamond (the old launcher's, which the owner liked), the centre in the
- // accent. Rings: 0 the centre, 1 the edges, 2 the corners (the ripple's order; corners and centre make the ×).
- const ORB_DOTS = [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]];
- function orbSVG() {
- const dots = ORB_DOTS.map(([x, y, r]) => `<circle class="ovo-d ovo-r${r}" cx="${x}" cy="${y}" r="${r ? 2.15 : 2.5}"/>`).join('');
- return `<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><g class="ovo-g">${dots}</g></svg>`;
+ // The OpenVibe menu's button (the owner's pick, 2026-11-10: the Arena's M57, "Search and apps"): the menu itself,
+ // small: a search bar over three apps. Open, the bar turns into one stroke of the close mark and the three apps
+ // line up as the other, so the button that opens the menu is, in the same place, the one that closes it. One
+ // drawing for the bar, the menu's sheet and the Dock's (root.OpenVibeOrb); its styles are below (.ovmb).
+ const ORB_PARTS = '<rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/>'
+ + '<rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/>';
+ function orbSVG(size = 22, open = false) {
+ return `<svg class="ovmb${open ? ' is-x' : ''}" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${ORB_PARTS}</svg>`;
}
}
+ root.OpenVibeOrb = { svg: orbSVG };
function orbHTML() {
function orbHTML() {
return `<button type="button" class="ovnav-search ovnav-orb" id="openvibe-search-btn" aria-label="OpenVibe menu: search, every site and tool (/)" title="Search and every OpenVibe site (/)" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>`;
return `<button type="button" class="ovnav-search ovnav-orb" id="openvibe-search-btn" aria-label="OpenVibe menu: search, every site and tool (/)" title="Search and every OpenVibe site (/)" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>`;
}
}
@@ -1064,34 +1071,10 @@
@@ -1064,34 +1071,10 @@
}
}
const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0];
const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0];
/** The chips folded into one (shown instead of them on a phone): each chip's icon and short value. */
/** The chips folded into one (shown instead of them on a phone): each chip's icon and short value. */
+ // New messages are counted on the bell (4.187.0; the owner, 2026-11-10: "instead of having messaging and
+ // notifications, just have the notifications … it'll show you new messages as well"): there is no Messages button.
let _msgCount = 0;
let _msgCount = 0;
- const msgLabel = () => `Messages${_msgCount ? ` (${_msgCount} unread)` : ''}`;
- function messagesHTML() {
- const m = _config.messages || {};
- const href = typeof m.href === 'string' ? m.href : 'https://openvibe.chat/messages';
- const n = _msgCount ? (_msgCount > 99 ? '99+' : String(_msgCount)) : '';
- return `<a class="ovnav-msgs" id="openvibe-messages-btn" href="${escapeAttr(href)}" title="${escapeAttr(m.label || 'Messages')}" aria-label="${escapeAttr(msgLabel())}">${navIcon('fa-comment-dots')}<b>${n}</b></a>`;
- }
- function bindMessages(nav) {
- const a = nav.querySelector('#openvibe-messages-btn'); if (!a || typeof a.addEventListener !== 'function') return;
- a.addEventListener('click', (e) => {
- const m = _config.messages || {};
- if (e.metaKey || e.ctrlKey || e.shiftKey) return;
- // The default (4.130.0): the Dock's Chats tab; without the Dock, the link.
- if (m.__default && _config.dock !== false) {
- e.preventDefault();
- if (_config.token) openDock('chats');
- else refreshAuthState().then(() => {
- if (_config.token) openDock('chats');
- else root.location.href = a.href;
- });
- return;
- }
- if (typeof m.onOpen !== 'function') return;
- e.preventDefault(); try { m.onOpen(e); } catch { /* the link still works */ }
- });
- }
- // The default Messages button's count: Chat's GET /api/dm/unread, at most once a minute (README, "The navbar").
+ // New messages, for the bell's count: Chat's GET /api/dm/unread, at most once a minute (README, "The navbar").
let _unreadAt = 0;
let _unreadAt = 0;
function loadUnread() {
function loadUnread() {
const m = _config.messages, u = _config.user;
const m = _config.messages, u = _config.user;
@@ -1355,7 +1338,9 @@
@@ -1355,7 +1338,9 @@
N.setBellAction(_dockOn ? () => {
N.setBellAction(_dockOn ? () => {
const D = root.OpenVibeDock; if (!D || typeof D.open !== 'function') return false;
const D = root.OpenVibeDock; if (!D || typeof D.open !== 'function') return false;
const st = typeof D._state === 'function' ? D._state() : {};
const st = typeof D._state === 'function' ? D._state() : {};
- if (D.isOpen() && st.tab === 'activity') D.close(); else D.open({ tab: 'activity' });
+ // New messages and nothing else new: the bell opens on them. Otherwise on Activity, as before.
+ const tab = _msgCount && N.unread && !N.unread() ? 'chats' : 'activity';
+ if (D.isOpen() && st.tab === tab) D.close(); else D.open({ tab });
return true;
return true;
} : null);
} : null);
}
}
@@ -2128,18 +2113,16 @@
@@ -2128,18 +2113,16 @@
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-right">
<div class="openvibe-navbar-right">
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
- ${u && !isAnon && _config.messages ? messagesHTML() : ''}
${featuredPhoneHTML(links)}
${featuredPhoneHTML(links)}
<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" 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)}">Sign In</a>`}
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
- ${links.length ? `<button type="button" class="ovnav-burger ovnav-orb" id="openvibe-burger" aria-label="Menu: this site, search and every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>` : ''}
</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>` : ''}
`;
`;
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
bindLinks(nav, links);
- bindMessages(nav);
hookSearch(nav);
hookSearch(nav);
hookSwitcher(nav);
hookSwitcher(nav);
bindChips(nav);
bindChips(nav);
@@ -2345,30 +2328,14 @@
@@ -2345,30 +2328,14 @@
}
}
const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open'));
const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open'));
- // ── The site switcher (site-switcher.js, 4.48.0): the OpenVibe mark opens a compact grid of every site ──
- // Recent and pinned follow the account (Network /api/themes/me/sites) when signed in; the name stays the home link.
- let _switcherLoading = false;
+ // ── The mark opens the OpenVibe menu on its first tab (4.187.0; the owner, 2026-11-10: the mark's own panel and
+ // the menu were two lists of the same sites, drawn differently and updated apart). One menu, two ways in. ──
function openSwitcher() {
function openSwitcher() {
- const btn = _navEl && _navEl.querySelector('#openvibe-switch-btn');
- const anchor = () => {
- if (!_navEl || !btn || !btn.getBoundingClientRect) return null;
- const bar = _navEl.getBoundingClientRect(), at = btn.getBoundingClientRect();
- return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6, cx: at.left + at.width / 2, cy: at.top + at.height / 2 } : null;
- };
- const go = () => {
- const S = root.OpenVibeSiteSwitcher;
- if (!S) return;
- if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close();
- if (_navEl) _navEl.classList.remove('ovnav-hide');
- S.open({ service: _config.service, frame: OVFrame.get((d) => S.update(d)), token: _config.token, apiBase: 'https://openvibe.network', user: _config.user,
- icons: (scope) => loadOvIcons(scope), anchor: anchor(),
- onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-switch-btn'); if (b) b.setAttribute('aria-expanded', 'false'); } });
- const b = _navEl && _navEl.querySelector('#openvibe-switch-btn'); if (b) b.setAttribute('aria-expanded', 'true');
- };
- if (root.OpenVibeSiteSwitcher) return go();
- if (_switcherLoading) return;
- _switcherLoading = true;
- try { const sc = document.createElement('script'); sc.src = siblingV('site-switcher.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _switcherLoading = false; }; document.head.appendChild(sc); } catch { _switcherLoading = false; }
+ if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }
+ // As if the menu button itself were pressed (the one on screen: beside the name on a wide bar, at the bar's end
+ // on a phone), so the sheet opens out of it and it becomes the close button, whichever way in was used.
+ const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
+ openSearch('', 'sites', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null);
}
}
// A signed-in person's visit moves this site to the front of their Recent, at most once in 30 minutes, and never
// A signed-in person's visit moves this site to the front of their Recent, at most once in 30 minutes, and never
// under Global Privacy Control or Do Not Track (as the page-view beacon).
// under Global Privacy Control or Do Not Track (as the page-view beacon).
@@ -2384,9 +2351,8 @@
@@ -2384,9 +2351,8 @@
function hookSwitcher(nav) {
function hookSwitcher(nav) {
const b = nav.querySelector('#openvibe-switch-btn');
const b = nav.querySelector('#openvibe-switch-btn');
if (!b) return;
if (!b) return;
- b.addEventListener('click', () => { if (root.OpenVibeSiteSwitcher && root.OpenVibeSiteSwitcher.isOpen()) root.OpenVibeSiteSwitcher.close(); else openSwitcher(); });
+ b.addEventListener('click', () => openSwitcher());
for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });
for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });
- if (root.OpenVibeSiteSwitcher && root.OpenVibeSiteSwitcher.isOpen()) b.setAttribute('aria-expanded', 'true');
// Once the page is idle: the visit never competes with the first paint or the sign-in check.
// Once the page is idle: the visit never competes with the first paint or the sign-in check.
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => recordVisit(), { timeout: 8000 });
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => recordVisit(), { timeout: 8000 });
else setTimeout(recordVisit, 3000);
else setTimeout(recordVisit, 3000);
@@ -2612,12 +2578,12 @@
@@ -2612,12 +2578,12 @@
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
- /** Messages beside the bell: messages: { href?, onOpen?(), label? } in init (or here), and the unread count. */
+ /** New messages: the count joins the bell's (there is no Messages button); a spec keeps `messages` on or off. */
setMessages(count, spec) {
setMessages(count, spec) {
- if (spec !== undefined) { _config.messages = spec; if (_navEl) render(); }
+ if (spec !== undefined) _config.messages = spec;
_msgCount = Math.max(0, Number(count) || 0);
_msgCount = Math.max(0, Number(count) || 0);
- const b = typeof document !== 'undefined' && document.querySelector('#openvibe-messages-btn b');
- if (b) { b.textContent = _msgCount ? (_msgCount > 99 ? '99+' : String(_msgCount)) : ''; b.parentElement.setAttribute('aria-label', msgLabel()); }
+ const N = root.OpenVibeNotifications;
+ if (N && typeof N.setMessages === 'function') N.setMessages(_msgCount);
},
},
/** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
/** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
@@ -100,6 +100,7 @@
@@ -100,6 +100,7 @@
let _pollTimer = null;
let _pollTimer = null;
let _lastSeenAt = null; // newest created_at we have toasted / seen
let _lastSeenAt = null; // newest created_at we have toasted / seen
let _unreadCount = 0;
let _unreadCount = 0;
+ let _msgs = 0; // new messages (navbar.js setMessages): on the bell's badge with the notifications, never in the list's own count
let _toastContainer = null;
let _toastContainer = null;
let _panelEl = null;
let _panelEl = null;
let _bellEl = null;
let _bellEl = null;
@@ -840,18 +841,22 @@
@@ -840,18 +841,22 @@
_bellEl = bell;
_bellEl = bell;
return bell;
return bell;
}
}
+ // The bell's own number: notifications and new messages together, each named in its label.
+ function paintBell(ring) {
+ if (!_bellEl) return;
+ const badge = _bellEl.querySelector('.badge'), n = _unreadCount + _msgs;
+ if (n > 0) {
+ badge.textContent = n > 99 ? '99+' : String(n);
+ badge.classList.remove('hidden');
+ if (ring) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 700); }
+ } else badge.classList.add('hidden');
+ const parts = [_unreadCount ? `${_unreadCount} unread` : '', _msgs ? `${_msgs} new ${_msgs === 1 ? 'message' : 'messages'}` : ''].filter(Boolean);
+ _bellEl.setAttribute('aria-label', parts.length ? `Notifications, ${parts.join(', ')}` : 'Notifications');
+ }
function setBadge(count, { quiet = false } = {}) {
function setBadge(count, { quiet = false } = {}) {
const prev = _unreadCount;
const prev = _unreadCount;
_unreadCount = Math.max(0, Number(count) || 0);
_unreadCount = Math.max(0, Number(count) || 0);
- if (_bellEl) {
- const badge = _bellEl.querySelector('.badge');
- if (_unreadCount > 0) {
- badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);
- badge.classList.remove('hidden');
- if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 700); }
- } else badge.classList.add('hidden');
- _bellEl.setAttribute('aria-label', _unreadCount ? `Notifications, ${_unreadCount} unread` : 'Notifications');
- }
+ paintBell(!quiet && _unreadCount > prev);
for (const v of _views.values()) {
for (const v of _views.values()) {
const c = v.root.querySelector('.openvibe-nv-head .count');
const c = v.root.querySelector('.openvibe-nv-head .count');
if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }
if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }
@@ -1444,6 +1449,10 @@
@@ -1444,6 +1449,10 @@
},
},
/** What a tap on the bell does instead of its own panel: fn() returns true when it handled it (navbar.js: the Dock). */
/** What a tap on the bell does instead of its own panel: fn() returns true when it handled it (navbar.js: the Dock). */
setBellAction(fn) { _bellAction = typeof fn === 'function' ? fn : null; },
setBellAction(fn) { _bellAction = typeof fn === 'function' ? fn : null; },
+ /** New messages, counted on the bell with the notifications (navbar.js): 0 takes them off. */
+ setMessages(count) { const prev = _msgs; _msgs = Math.max(0, Number(count) || 0); paintBell(_msgs > prev); },
+ /** How many notifications are unread (not counting messages). */
+ unread() { return _unreadCount; },
/** Create and inject the bell icon. Returns the DOM element. */
/** Create and inject the bell icon. Returns the DOM element. */
createBell(container) {
createBell(container) {
injectStyles();
injectStyles();
@@ -113,6 +113,15 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
@@ -113,6 +113,15 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
.ovh-hint { margin: 10px 6px 4px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovh-hint { margin: 10px 6px 4px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovh-calm { transition: opacity .2s; }
.ovh-calm { transition: opacity .2s; }
.ovh.is-edit .ovh-calm { opacity: .38; pointer-events: none; }
.ovh.is-edit .ovh-calm { opacity: .38; pointer-events: none; }
+.ovh-headtools { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
+.ovh-sec .ovh-allbtn { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 11px 0 10px; border-radius: 999px; cursor: pointer; font-size: 13px; font-weight: 700;
+ color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 7%, transparent); transition: color .15s, background-color .15s, transform .12s; }
+.ovh-sec .ovh-allbtn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.ovh-sec .ovh-allbtn b { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8ecf3); }
+.ovh-sec .ovh-allbtn:hover { color: var(--text-primary, #e8ecf3); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 13%, transparent); }
+.ovh-sec .ovh-allbtn:active { transform: scale(.96); }
+.ovh-sec .ovh-allbtn:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+@media (pointer: coarse) { .ovh-sec .ovh-allbtn { min-height: 44px; padding: 0 13px 0 12px; } }
.ovh-all[hidden], .ovh[hidden] { display: none !important; }
.ovh-all[hidden], .ovh[hidden] { display: none !important; }
.ovh-all { display: flex; align-items: center; justify-content: center; gap: 8px; width: calc(100% - 8px); min-height: 44px; margin: 6px 4px 2px; border: 1px solid var(--border, rgba(255,255,255,.12));
.ovh-all { display: flex; align-items: center; justify-content: center; gap: 8px; width: calc(100% - 8px); min-height: 44px; margin: 6px 4px 2px; border: 1px solid var(--border, rgba(255,255,255,.12));
border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
@@ -326,6 +335,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -326,6 +335,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const ICON = {
const ICON = {
pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],
pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],
check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],
check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],
+ grid: [['rect', { x: 4, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 4, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }]],
look: [['path', { d: 'M12 3a9 9 0 1 0 0 18c1 0 1.7-.8 1.7-1.7 0-.5-.2-.9-.5-1.2-.3-.3-.4-.7-.4-1.1 0-1 .8-1.7 1.7-1.7h2.1A4.4 4.4 0 0 0 21 11c0-4.4-4-8-9-8Z' }],
look: [['path', { d: 'M12 3a9 9 0 1 0 0 18c1 0 1.7-.8 1.7-1.7 0-.5-.2-.9-.5-1.2-.3-.3-.4-.7-.4-1.1 0-1 .8-1.7 1.7-1.7h2.1A4.4 4.4 0 0 0 21 11c0-4.4-4-8-9-8Z' }],
['circle', { cx: 7.6, cy: 11.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 10.4, cy: 7.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 15, cy: 7.6, r: 1.1, fill: 'currentColor', stroke: 'none' }]],
['circle', { cx: 7.6, cy: 11.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 10.4, cy: 7.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 15, cy: 7.6, r: 1.1, fill: 'currentColor', stroke: 'none' }]],
gear: [['circle', { cx: 12, cy: 12, r: 3 }], ['path', { d: 'M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z' }]],
gear: [['circle', { cx: 12, cy: 12, r: 3 }], ['path', { d: 'M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z' }]],
@@ -497,16 +507,20 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -497,16 +507,20 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
edit.title = state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders';
edit.title = state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders';
edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');
edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');
edit.addEventListener('click', () => setEdit(!state.edit));
edit.addEventListener('click', () => setEdit(!state.edit));
- head.append(edit);
+ // All apps sits beside Edit, small, with its count (the owner, 2026-11-10: "all apps should just be in a
+ // button in the corner so it's more compact"): it was a bar the width of the sheet under the grid.
+ const total = L.byId.size;
+ const all = h('button', 'ovh-allbtn'); all.type = 'button';
+ all.append(svgIcon('grid'), h('span', '', 'All'), h('b', '', String(total)));
+ all.setAttribute('aria-label', `All apps (${total})`); all.title = `All apps (${total})`;
+ all.addEventListener('click', () => { state.view = 'drawer'; paint(); });
+ const tools = h('div', 'ovh-headtools'); tools.append(all, edit);
+ head.append(tools);
el.append(head);
el.append(head);
const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');
const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');
L.home.forEach((id, i) => grid.append(homeTile(id, i)));
L.home.forEach((id, i) => grid.append(homeTile(id, i)));
el.append(grid);
el.append(grid);
- if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All apps puts it back.'));
- const total = L.byId.size;
- const all = h('button', 'ovh-all', `All apps (${total})`); all.type = 'button';
- all.addEventListener('click', () => { state.view = 'drawer'; paint(); });
- el.append(all);
+ if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All puts it back.'));
// Under your home: what you use most (shortcuts), then where you were last (history).
// Under your home: what you use most (shortcuts), then where you were last (history).
{ const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }
{ const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }
icons();
icons();
@@ -701,7 +715,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -701,7 +715,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
function backHead(title) {
function backHead(title) {
const head = h('div', 'ovh-head');
const head = h('div', 'ovh-head');
const back = h('button', 'ovh-back', '← Home'); back.type = 'button';
const back = h('button', 'ovh-back', '← Home'); back.type = 'button';
- back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-all'); if (b) b.focus(); });
+ back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });
head.append(back, h('b', '', title));
head.append(back, h('b', '', title));
return head;
return head;
}
}
@@ -56,6 +56,9 @@
@@ -56,6 +56,9 @@
if (window.__ovMark) return; window.__ovMark = true;
if (window.__ovMark) return; window.__ovMark = true;
let n = 0;
let n = 0;
const f = (v) => +v.toFixed(2);
const f = (v) => +v.toFixed(2);
+ // The ground around the V where it crosses the O: the wide gap (the owner's pick, 2026-11-10, the Arena's P07:
+ // "more ground around the V where it crosses"); it was 7.
+ const HALO = 14;
// The Pair in a 100-unit box, centred on (50, 50) (openvibe-shared/mark: the same numbers; test/mark.test.js).
// The Pair in a 100-unit box, centred on (50, 50) (openvibe-shared/mark: the same numbers; test/mark.test.js).
function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;
const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;
@@ -73,7 +76,7 @@
@@ -73,7 +76,7 @@
err: [mx, A[1], mx, T[1] - vsw * 1.28, mx, T[1], mx, T[1] + 0.01],
err: [mx, A[1], mx, T[1] - vsw * 1.28, mx, T[1], mx, T[1] + 0.01],
};
};
for (const k in shapes) shapes[k] = shapes[k].map(f);
for (const k in shapes) shapes[k] = shapes[k].map(f);
- return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + 7, A, T, B, ring, box, wideBox, shapes };
+ return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + HALO, A, T, B, ring, box, wideBox, shapes };
}
}
const FORMS = { pair: pair(20, 13, 13), heavy: pair(18, 18, 17) };
const FORMS = { pair: pair(20, 13, 13), heavy: pair(18, 18, 17) };
const formOf = (v) => (v === 'heavy' || v === 'solid' ? 'heavy' : 'pair');
const formOf = (v) => (v === 'heavy' || v === 'solid' ? 'heavy' : 'pair');
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.186.0",
+ "version": "4.187.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.186.0",
+ "version": "4.187.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.186.0",
+ "version": "4.187.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": {
@@ -101,7 +101,6 @@
@@ -101,7 +101,6 @@
"./tokens": "./tokens.js",
"./tokens": "./tokens.js",
"./components": "./components.js",
"./components": "./components.js",
"./appbar": "./appbar.js",
"./appbar": "./appbar.js",
- "./site-switcher": "./site-switcher.js",
"./search": "./search.js",
"./search": "./search.js",
"./agent-session": "./agent-session.js",
"./agent-session": "./agent-session.js",
"./loading": "./loading.js",
"./loading": "./loading.js",
@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// owner still saw the old menu hours after the new one shipped (11-07).
// owner still saw the old menu hours after the new one shipped (11-07).
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',
+ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',
// 4.173.0: the wallet sheet (4.170.0) went out without ?v=, so a fix to it reached phones up to a day late.
// 4.173.0: the wallet sheet (4.170.0) went out without ?v=, so a fix to it reached phones up to a day late.
'ov-wallet.js', 'ov-wallet.css'];
'ov-wallet.js', 'ov-wallet.css'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
@@ -382,13 +382,6 @@
@@ -382,13 +382,6 @@
.ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }
.ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }
.ovs.is-closing { animation: ovs-fade .28s ease-in both; }
.ovs.is-closing { animation: ovs-fade .28s ease-in both; }
}
}
-.ovs-orb .ovo-g { transform-origin: 12px 12px; transform: rotate(45deg) scale(.8); transition: transform .5s cubic-bezier(.2, 1.35, .3, 1); }
-.ovs-orb .ovo-d { fill: currentColor; transform-box: fill-box; transform-origin: center; transition: fill .3s, opacity .25s, transform .32s cubic-bezier(.2, 1.4, .3, 1); }
-.ovs-orb .ovo-r0 { fill: var(--ovo-a); }
-.ovs-orb .ovo-r2 { opacity: .6; }
-.ovs-orb.is-x .ovo-g { transform: rotate(180deg) scale(.95); }
-.ovs-orb.is-x .ovo-r1 { opacity: 0; transform: scale(0); }
-.ovs-orb.is-x :is(.ovo-r0, .ovo-r2) { fill: var(--ovo-a); opacity: 1; transform: scale(1.12); }
`;
`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
@@ -403,11 +396,10 @@
@@ -403,11 +396,10 @@
grid: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></svg>',
grid: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></svg>',
};
};
- // The orb (navbar.js draws the same nine dots): on a phone the menu covers the bar, so the menu draws the orb in the
+ // The orb (navbar.js's drawing, root.OpenVibeOrb): on a phone the menu covers the bar, so the menu draws the orb in the
// button's own place, on top, and it turns into the menu's close (4.150.0, the owner: "it renders on top … turns into
// button's own place, on top, and it turns into the menu's close (4.150.0, the owner: "it renders on top … turns into
// the close button when it's open … a cool effect as it goes in and out").
// the close button when it's open … a cool effect as it goes in and out").
- const ORB_DOTS = [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]];
- const orbSvg = () => `<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><g class="ovo-g">${ORB_DOTS.map(([x, y, r]) => `<circle class="ovo-d ovo-r${r}" cx="${x}" cy="${y}" r="${r ? 2.15 : 2.5}"/>`).join('')}</g></svg>`;
+ const orbSvg = () => (root.OpenVibeOrb ? root.OpenVibeOrb.svg(24) : '');
let orbEl = null, closing = 0;
let orbEl = null, closing = 0;
const phoneSheet = () => { try { return !!(el && el.classList.contains('ovs-anchored') && root.matchMedia('(max-width: 640px)').matches); } catch { return false; } };
const phoneSheet = () => { try { return !!(el && el.classList.contains('ovs-anchored') && root.matchMedia('(max-width: 640px)').matches); } catch { return false; } };
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
deleted file mode 100644
deleted file mode 100644
@@ -1,249 +0,0 @@
@@ -1,249 +0,0 @@
-/**
- * site-switcher.js — the site switcher behind the OpenVibe icon (4.48.0).
- *
- * The owner, 2026-10-30: "clicking the icon … should have its own drop-down that lets you very quickly … a more compact
- * view of all the OpenVibe domains … tools and services … all the sites, so you can hop between them fast and easy.
- * And make it ordered based on whatever makes sense." The navbar loads this on the first tap of its mark (as it loads
- * search.js for the menu) and calls OpenVibeSiteSwitcher.open().
- *
- * Order:
- * - Recent: the sites this person was on last (their account's list when signed in, from
- * /api/themes/me/sites; otherwise the sites they jumped to from here, kept in this site's storage).
- * - Pinned: the sites they pinned (hold a site, or right-click it).
- * - Then every open site, grouped by what people come to do (the frame's launcher groups). Inside a group the frame's
- * order is kept: most used first.
- *
- * Use:
- * - Type to filter. The arrow keys move, Enter goes, Esc closes.
- * - On phones it is a sheet under the bar that keeps its own scroll and locks the page behind it.
- * - The site's name in the bar stays its link home.
- *
- * The switcher is DOM nodes only: no text from the frame is parsed as HTML, and links must be https.
- */
-(function (root) {
- 'use strict';
- if (typeof document === 'undefined' || root.OpenVibeSiteSwitcher) return;
-
- const STYLE_ID = 'ov-switcher-style';
- const LOCAL = 'ov_sw_sites';
- const SITE_ID = /^[a-z][a-z0-9-]{1,31}$/;
- const ACCOUNT = 'https://openvibe.network/my';
- const CSS = `
-.ovsw { position: fixed; inset: 0; z-index: 10021; display: none; background: rgba(3, 6, 12, .32); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
-.ovsw.is-open { display: block; }
-.ovsw-panel { position: fixed; top: var(--ovsw-top, 64px); left: var(--ovsw-left, 8px); width: min(560px, calc(100vw - 16px)); max-height: calc(100dvh - var(--ovsw-top, 64px) - 12px); overflow-y: auto; overscroll-behavior: contain; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 12px; box-sizing: border-box; border-radius: 20px; background: var(--bg-secondary, #101828); color: var(--text-primary, #e6edf7); box-shadow: 0 0 0 1px var(--border-light, rgba(255,255,255,.12)), 0 22px 60px rgba(0,0,0,.45); transform-origin: 24px -6px; animation: ovsw-in .34s cubic-bezier(.2, 1.2, .3, 1) both; }
-@media (max-width: 640px) { .ovsw-panel { left: 0; width: 100vw; border-radius: 0 0 22px 22px; } }
-.ovsw-top { display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 8px 0 12px; border-radius: 13px; background: var(--bg-primary, #0a0f1c); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }
-.ovsw-top:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
-.ovsw-top svg { width: 17px; height: 17px; flex: none; fill: none; stroke: var(--text-muted, #8192ac); stroke-width: 2.2; stroke-linecap: round; }
-.ovsw-top input { all: unset; flex: 1; min-width: 0; font-size: 15px; color: var(--text-primary, #e6edf7); }
-.ovsw-top input::placeholder { color: var(--text-muted, #8192ac); }
-.ovsw-top input::-webkit-search-cancel-button { display: none; }
-.ovsw-top kbd { font: 600 10.5px/1 inherit; padding: 4px 6px; border-radius: 6px; color: var(--text-muted, #8192ac); box-shadow: inset 0 0 0 1px var(--border-light, rgba(255,255,255,.14)); }
-@media (pointer: coarse) { .ovsw-top kbd { display: none; } }
-.ovsw-h { display: flex; justify-content: space-between; gap: 8px; margin: 0; padding: 0 4px 4px; font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted, #8192ac); }
-.ovsw-h span { font-weight: 500; letter-spacing: 0; text-transform: none; }
-.ovsw-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(68px, 1fr)); gap: 2px; }
-.ovsw-row { position: relative; display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
-.ovsw-row::-webkit-scrollbar { display: none; }
-.ovsw-row .ovsw-t { flex: 0 0 72px; }
-.ovsw-t { all: unset; box-sizing: border-box; position: relative; display: grid; justify-items: center; gap: 5px; padding: 8px 2px 7px; border-radius: 13px; cursor: pointer; font-family: 'OV Brand', Nunito, 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-size: 12px; font-weight: 800; line-height: 1.15; text-align: center; color: var(--text-secondary, #a8b3c4); -webkit-touch-callout: none; user-select: none; animation: ovsw-tile .36s cubic-bezier(.2, 1.3, .3, 1) both; animation-delay: calc(var(--k, 0) * 10ms + 50ms); }
-.ovsw-t:hover, .ovsw-t:focus-visible { outline: none; color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
-.ovsw-t:focus-visible { box-shadow: inset 0 0 0 1.5px var(--accent, #3b82f6); }
-.ovsw-t .ov-icon { width: 36px; height: 36px; transition: transform .26s cubic-bezier(.2, 1.4, .3, 1); }
-.ovsw-t:hover .ov-icon { transform: translateY(-2px) scale(1.06); }
-.ovsw-t.is-here .ov-icon { border-radius: 11px; box-shadow: 0 0 0 2px var(--bg-secondary, #101828), 0 0 0 4px var(--accent, #3b82f6); }
-.ovsw-t.is-here::after { content: 'here'; position: absolute; top: 2px; right: 6px; font-size: 9px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); }
-.ovsw-pin { position: absolute; top: 2px; left: 6px; font-size: 10px; color: #f59e0b; }
-.ovsw-none { margin: 0; padding: 8px 4px; font-size: 14px; color: var(--text-secondary, #a8b3c4); }
-.ovsw-foot { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 2px 4px 0; font-size: 12.5px; color: var(--text-muted, #8192ac); }
-.ovsw-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 650; text-decoration: none; }
-.ovsw-foot a:hover { text-decoration: underline; }
-@keyframes ovsw-in { from { opacity: 0; transform: translateY(-8px) scale(.97); } }
-@keyframes ovsw-tile { from { opacity: 0; transform: translateY(6px) scale(.94); } }
-`;
-
- let el = null, panel = null, ctx = {}, lastFocus = null, mine = null, query = '';
- const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
- const ids = (a, n) => [...new Set((Array.isArray(a) ? a : []).map(String).filter((x) => SITE_ID.test(x)))].slice(0, n);
- function localSites() {
- try { const d = JSON.parse(localStorage.getItem(LOCAL) || 'null') || {}; return { pinned: ids(d.pinned, 12), recent: ids(d.recent, 8) }; } catch { return { pinned: [], recent: [] }; }
- }
- function saveLocal(d) { try { localStorage.setItem(LOCAL, JSON.stringify({ pinned: ids(d.pinned, 12), recent: ids(d.recent, 8) })); } catch { /* storage may be off */ } }
- function headers(json) {
- const h = { Accept: 'application/json' };
- if (json) h['Content-Type'] = 'application/json';
- if (ctx.token) h.Authorization = 'Bearer ' + ctx.token;
- return h;
- }
- const signedIn = () => !!(ctx.user && ctx.apiBase);
- function loadMine() {
- if (!signedIn() || typeof fetch === 'undefined') return Promise.resolve(localSites());
- return fetch(ctx.apiBase + '/api/themes/me/sites', { headers: headers(), credentials: 'include' })
- .then((r) => (r.ok ? r.json() : null))
- .then((d) => (d ? { pinned: ids(d.pinned, 12), recent: ids(d.recent, 8) } : localSites()))
- .catch(() => localSites());
- }
- function savePins(pinned) {
- const cur = mine || localSites();
- mine = { pinned: ids(pinned, 12), recent: cur.recent };
- saveLocal(mine);
- if (signedIn() && typeof fetch !== 'undefined') fetch(ctx.apiBase + '/api/themes/me/sites', { method: 'PUT', headers: headers(true), credentials: 'include', body: JSON.stringify({ pinned: mine.pinned }) }).catch(() => {});
- }
- function noteJump(id) {
- const cur = localSites();
- saveLocal({ pinned: cur.pinned, recent: [id, ...cur.recent] });
- }
-
- function style() {
- if (document.getElementById(STYLE_ID)) return;
- const s = document.createElement('style'); s.id = STYLE_ID; s.textContent = CSS; document.head.appendChild(s);
- }
- function sites() {
- const f = ctx.frame || {};
- return (Array.isArray(f.nav) ? f.nav : []).filter((s) => s && SITE_ID.test(s.id) && https(s.url));
- }
- function lens() {
- const ns = 'http://www.w3.org/2000/svg', svg = document.createElementNS(ns, 'svg');
- svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true');
- const c = document.createElementNS(ns, 'circle'); c.setAttribute('cx', '11'); c.setAttribute('cy', '11'); c.setAttribute('r', '7');
- const p = document.createElementNS(ns, 'path'); p.setAttribute('d', 'm21 21-4.3-4.3');
- svg.append(c, p); return svg;
- }
- function build() {
- style();
- el = document.createElement('div');
- el.className = 'ovsw';
- el.addEventListener('pointerdown', (e) => { if (e.target === el) close(); });
- panel = document.createElement('div');
- panel.className = 'ovsw-panel';
- panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); panel.setAttribute('aria-label', 'Switch to another OpenVibe site');
- panel.addEventListener('keydown', onKey);
- el.appendChild(panel);
- document.body.appendChild(el);
- }
- // The page's own site: by its address (one service can serve several sites: media-hub is Video, Pics and Download).
- const isHere = (s) => { try { return new URL(s.url).hostname === location.hostname; } catch { return s.id === ctx.service; } };
- function tile(s, k, opts) {
- const here = isHere(s);
- const b = document.createElement('a');
- b.className = 'ovsw-t' + (here ? ' is-here' : '');
- b.href = s.url;
- b.style.setProperty('--k', String(k));
- b.title = s.tagline ? `${s.name}: ${s.tagline}` : s.name;
- if (here) b.setAttribute('aria-current', 'page');
- const ic = document.createElement('span'); ic.className = 'ov-icon'; ic.setAttribute('data-icon', s.id); ic.setAttribute('data-size', '36'); ic.setAttribute('aria-hidden', 'true');
- b.appendChild(ic);
- b.appendChild(document.createTextNode(s.name));
- if (opts && opts.pinned) { const p = document.createElement('span'); p.className = 'ovsw-pin'; p.textContent = '★'; p.setAttribute('aria-label', 'pinned'); b.appendChild(p); }
- // Hold (touch) or right-click: pin or unpin.
- let hold = 0, held = false;
- const toggle = () => { const cur = (mine || localSites()).pinned; savePins(cur.includes(s.id) ? cur.filter((x) => x !== s.id) : [s.id, ...cur]); render(); };
- b.addEventListener('pointerdown', (e) => { held = false; if (e.pointerType !== 'mouse') hold = setTimeout(() => { held = true; toggle(); }, 480); });
- b.addEventListener('pointerup', () => clearTimeout(hold));
- b.addEventListener('pointerleave', () => clearTimeout(hold));
- b.addEventListener('contextmenu', (e) => { e.preventDefault(); toggle(); });
- b.addEventListener('click', (e) => { if (held) { e.preventDefault(); held = false; return; } if (here) { e.preventDefault(); close(); return; } noteJump(s.id); });
- return b;
- }
- function section(title, hint, list, row, pinnedSet) {
- const wrap = document.createElement('section');
- const h = document.createElement('p'); h.className = 'ovsw-h'; h.textContent = title;
- if (hint) { const sp = document.createElement('span'); sp.textContent = hint; h.appendChild(sp); }
- const grid = document.createElement('div'); grid.className = row ? 'ovsw-row' : 'ovsw-grid';
- list.forEach((s, k) => grid.appendChild(tile(s, k, { pinned: pinnedSet && pinnedSet.has(s.id) })));
- wrap.append(h, grid);
- return wrap;
- }
- function render() {
- if (!panel) return;
- const keep = panel.querySelector('input');
- const hadFocus = keep && document.activeElement === keep;
- const pos = keep ? keep.selectionStart : null;
- panel.textContent = '';
- const top = document.createElement('label'); top.className = 'ovsw-top';
- const input = document.createElement('input');
- input.type = 'search'; input.placeholder = 'Jump to a site'; input.autocomplete = 'off'; input.setAttribute('aria-label', 'Filter the sites'); input.value = query;
- input.addEventListener('input', () => { query = input.value; render(); });
- const kbd = document.createElement('kbd'); kbd.textContent = 'Esc';
- top.append(lens(), input, kbd);
- panel.appendChild(top);
- const all = sites(), byId = new Map(all.map((s) => [s.id, s]));
- const m = mine || localSites(), pinnedSet = new Set(m.pinned);
- const q = query.trim().toLowerCase();
- const groups = (ctx.frame && Array.isArray(ctx.frame.groups) && ctx.frame.groups.length) ? ctx.frame.groups : [{ id: '', name: 'OpenVibe' }];
- if (!all.length) {
- const p = document.createElement('p'); p.className = 'ovsw-none'; p.textContent = 'Loading the sites…'; panel.appendChild(p);
- } else if (q) {
- const hits = all.filter((s) => s.name.toLowerCase().includes(q) || s.id.includes(q) || String(s.tagline || '').toLowerCase().includes(q));
- if (hits.length) panel.appendChild(section(`${hits.length} ${hits.length === 1 ? 'site' : 'sites'}`, 'Enter opens the first', hits, false, pinnedSet));
- else { const p = document.createElement('p'); p.className = 'ovsw-none'; p.textContent = `No site called "${query.trim()}".`; panel.appendChild(p); }
- } else {
- const recent = m.recent.map((id) => byId.get(id)).filter((s) => s && !isHere(s));
- if (recent.length) panel.appendChild(section('Recent', signedIn() ? 'where you were last' : 'where you went from here', recent, true));
- const pinned = m.pinned.map((id) => byId.get(id)).filter(Boolean);
- panel.appendChild(pinned.length ? section('Pinned', 'hold a site to unpin it', pinned, false, pinnedSet) : (() => { const p = document.createElement('p'); p.className = 'ovsw-h'; p.textContent = 'Pinned'; const sp = document.createElement('span'); sp.textContent = 'hold a site (or right-click) to pin it'; p.appendChild(sp); return p; })());
- // Each group in the frame's order, most used first inside it; a site in a group this release does not know
- // joins the last group (as the menu's Sites tab does).
- const known = new Set(groups.map((g) => g.id));
- groups.forEach((g, gi) => {
- const list = all.filter((s) => (g.id ? s.group === g.id : true) || (gi === groups.length - 1 && !known.has(s.group)));
- if (list.length) panel.appendChild(section(g.name, '', list, false, pinnedSet));
- });
- }
- const foot = document.createElement('div'); foot.className = 'ovsw-foot';
- const count = document.createElement('span'); count.textContent = `${all.length || ''}${all.length ? ' sites · ' : ''}one account`;
- const acct = document.createElement('a'); acct.href = ACCOUNT; acct.textContent = 'Your account →';
- foot.append(count, acct);
- panel.appendChild(foot);
- if (typeof ctx.icons === 'function') { try { ctx.icons(panel); } catch { /* icons are optional */ } }
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') { const first = panel.querySelector('.ovsw-t:not(.is-here)'); if (first) { e.preventDefault(); first.click(); } }
- if (e.key === 'ArrowDown') { const first = panel.querySelector('.ovsw-t'); if (first) { e.preventDefault(); first.focus(); } }
- });
- if (hadFocus) { input.focus(); try { input.setSelectionRange(pos, pos); } catch { /* */ } }
- }
- function onKey(e) {
- if (e.key === 'Escape') { e.preventDefault(); close(); return; }
- const tiles = [...panel.querySelectorAll('.ovsw-t')], i = tiles.indexOf(document.activeElement);
- if (i < 0) return;
- const row = tiles[i].parentElement, cols = Math.max(1, Math.round(row.clientWidth / Math.max(1, tiles[i].offsetWidth)));
- const go = (j) => { if (tiles[j]) { e.preventDefault(); tiles[j].focus(); } };
- if (e.key === 'ArrowRight') go(i + 1);
- else if (e.key === 'ArrowLeft') go(i - 1);
- else if (e.key === 'ArrowDown') go(Math.min(tiles.length - 1, i + cols));
- else if (e.key === 'ArrowUp') { if (i - cols < 0) { e.preventDefault(); const inp = panel.querySelector('input'); if (inp) inp.focus(); } else go(i - cols); }
- }
-
- function open(opts) {
- ctx = opts || {};
- if (!el) build();
- lastFocus = document.activeElement;
- query = '';
- const at = ctx.anchor;
- el.style.setProperty('--ovsw-top', `${Math.max(0, Math.round(at && at.top || 64))}px`);
- el.style.setProperty('--ovsw-left', `${Math.max(8, Math.round(at && at.left || 8))}px`);
- el.classList.add('is-open');
- document.documentElement.style.overflow = 'hidden';
- try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true, switcher: true } })); } catch { /* */ }
- render();
- // A pointer that can hover gets the filter focused; a phone keeps its keyboard down until someone taps it.
- if (typeof matchMedia === 'function' && matchMedia('(pointer: fine)').matches) { const inp = panel.querySelector('input'); if (inp) setTimeout(() => inp.focus(), 0); }
- else setTimeout(() => { const t = panel.querySelector('.ovsw-t'); if (t && t.focus) t.focus({ preventScroll: true }); }, 0);
- loadMine().then((d) => { mine = d; if (isOpen()) render(); });
- }
- function update(frame) { if (frame) { ctx.frame = frame; if (isOpen()) render(); } }
- function close() {
- if (!el || !el.classList.contains('is-open')) return;
- el.classList.remove('is-open');
- document.documentElement.style.overflow = '';
- if (typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }
- try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: false, switcher: true } })); } catch { /* */ }
- if (lastFocus && lastFocus.focus) lastFocus.focus();
- }
- const isOpen = () => !!(el && el.classList.contains('is-open'));
-
- const api = { open, close, update, isOpen };
- root.OpenVibeSiteSwitcher = api;
- if (typeof module === 'object' && module.exports) module.exports = api;
-})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -34,7 +34,6 @@ assert.strictEqual(files.path('ov-brand.woff2'), path.join(__dirname, '..', 'ov-
@@ -34,7 +34,6 @@ assert.strictEqual(files.path('ov-brand.woff2'), path.join(__dirname, '..', 'ov-
const fallback = /font-family: ?'OV Brand', ?Nunito, ?'?Inter'?, ?-apple-system/;
const fallback = /font-family: ?'OV Brand', ?Nunito, ?'?Inter'?, ?-apple-system/;
assert.match(nav, new RegExp(`\\.openvibe-navbar-brand \\.name \\{[^}]*${fallback.source}`), 'the site name');
assert.match(nav, new RegExp(`\\.openvibe-navbar-brand \\.name \\{[^}]*${fallback.source}`), 'the site name');
assert.match(read('footer.js'), new RegExp(`\\.ovf-brand\\{[^}]*${fallback.source}`), "the footer's brand");
assert.match(read('footer.js'), new RegExp(`\\.ovf-brand\\{[^}]*${fallback.source}`), "the footer's brand");
- assert.match(read('site-switcher.js'), new RegExp(`\\.ovsw-t \\{[^}]*${fallback.source}`), "the switcher's names");
const cards = read('scripts/build-social-cards.js');
const cards = read('scripts/build-social-cards.js');
assert.ok(/function brandFace\(\)[\s\S]*ov-brand\.woff2/.test(cards) && /\.name\{[^}]*font-family:'OV Brand'/.test(cards), 'the share cards, with the face embedded');
assert.ok(/function brandFace\(\)[\s\S]*ov-brand\.woff2/.test(cards) && /\.name\{[^}]*font-family:'OV Brand'/.test(cards), 'the share cards, with the face embedded');
@@ -21,5 +21,4 @@ files.BROWSER.forEach((name) => {
@@ -21,5 +21,4 @@ files.BROWSER.forEach((name) => {
}
}
});
});
assert.deepStrictEqual([...new Set(missing)], [], 'files a browser file loads but no site serves');
assert.deepStrictEqual([...new Set(missing)], [], 'files a browser file loads but no site serves');
-assert.ok(files.BROWSER.includes('site-switcher.js'), 'the mark\'s switcher is served');
console.log('browser files complete: all checks passed');
console.log('browser files complete: all checks passed');
@@ -38,7 +38,7 @@ for (const form of ['pair', 'heavy']) {
@@ -38,7 +38,7 @@ for (const form of ['pair', 'heavy']) {
// The standalone SVG: the gap is a mask, so it holds on any ground; the tones; the wide box.
// The standalone SVG: the gap is a mask, so it holds on any ground; the tones; the wide box.
const svg = mark.svg({ size: 64, title: 'OpenVibe', depth: false }); // the flat colours (depth is checked below)
const svg = mark.svg({ size: 64, title: 'OpenVibe', depth: false }); // the flat colours (depth is checked below)
assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="[\d. ]+" width="64" height="64" role="img" aria-label="OpenVibe"><title>OpenVibe<\/title>/);
assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="[\d. ]+" width="64" height="64" role="img" aria-label="OpenVibe"><title>OpenVibe<\/title>/);
-assert.match(svg, /<mask id="ovmh"[\s\S]*stroke="#000" stroke-width="20"[\s\S]*<\/mask>[\s\S]*<circle [^>]*stroke="#4f7cff"[^>]*mask="url\(#ovmh\)"\/><path [^>]*stroke="#8b5cf6"/, 'blue O cut by a mask, violet V on top');
+assert.match(svg, /<mask id="ovmh"[\s\S]*stroke="#000" stroke-width="27"[\s\S]*<\/mask>[\s\S]*<circle [^>]*stroke="#4f7cff"[^>]*mask="url\(#ovmh\)"\/><path [^>]*stroke="#8b5cf6"/, 'blue O cut by a mask, violet V on top');
assert.match(mark.svg({ tone: 'ink' }), /stroke="currentColor"[\s\S]*stroke="currentColor"/, 'ink: the text colour');
assert.match(mark.svg({ tone: 'ink' }), /stroke="currentColor"[\s\S]*stroke="currentColor"/, 'ink: the text colour');
assert.match(mark.svg({ tone: 'white' }), /stroke="#fff"[\s\S]*stroke="#fff"/, 'white: for a colour tile');
assert.match(mark.svg({ tone: 'white' }), /stroke="#fff"[\s\S]*stroke="#fff"/, 'white: for a colour tile');
const wide = mark.svg({ wide: true, size: 40 });
const wide = mark.svg({ wide: true, size: 40 });
@@ -68,7 +68,7 @@ async function openMenu(p) {
@@ -68,7 +68,7 @@ async function openMenu(p) {
assert.strictEqual(x.vw, 390, 'a phone layout');
assert.strictEqual(x.vw, 390, 'a phone layout');
assert.ok(x.w >= 40 && x.h >= 40 && x.right <= x.vw && x.top >= 0, `a 40 px target on screen: ${JSON.stringify(x)}`);
assert.ok(x.w >= 40 && x.h >= 40 && x.right <= x.vw && x.top >= 0, `a 40 px target on screen: ${JSON.stringify(x)}`);
assert.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'the orb, named for a screen reader');
assert.deepStrictEqual([x.label, x.icon, x.word], ['Close the menu', true, false], 'the orb, named for a screen reader');
- assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovo-d').length === 9; })()`), true, 'its nine dots have turned into an x');
+ assert.strictEqual(await p.evaluate(`(() => { const o = document.querySelector('.ovs.is-open .ovs-orb'); return o.classList.contains('is-x') && o.querySelectorAll('.ovmb .ovmb-app').length === 3 && getComputedStyle(o.querySelector('.ovmb-bar')).transform !== 'none'; })()`), true, 'its bar and three apps have crossed into an x');
assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb');
assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovs.is-open .ovs-x')).display`), 'none', 'one close: the round x steps aside for the orb');
const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-orb').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-orb'); })()`);
const hit = await p.evaluate(`(() => { const b = document.querySelector('.ovs.is-open .ovs-orb').getBoundingClientRect(); const e = document.elementFromPoint(b.left - 2, b.top + b.height / 2); return !!e && !!e.closest('.ovs-orb'); })()`);
assert.ok(hit, 'the hit area reaches past the circle');
assert.ok(hit, 'the hit area reaches past the circle');
@@ -103,7 +103,7 @@ const server = http.createServer((req, res) => {
@@ -103,7 +103,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');
assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');
assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');
assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');
assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches');
assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches');
- assert.ok(await p.evaluate(`/All apps \\(\\d+\\)/.test(document.querySelector('.ovh-all').textContent)`), 'All apps says how many');
+ assert.ok(await p.evaluate(`(() => { const b = document.querySelector('.ovh-headtools .ovh-allbtn'); return !!b && /All apps \\(\\d+\\)/.test(b.getAttribute('aria-label')) && /^All\\d+$/.test(b.textContent.replace(/\\s/g, '')) && b.getBoundingClientRect().width < 120; })()`), 'All apps is a small button beside Edit, and says how many');
assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip');
assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip');
// A long-press starts Edit, and the same press drags the first app onto the third's place.
// A long-press starts Edit, and the same press drags the first app onto the third's place.
@@ -135,7 +135,7 @@ const server = http.createServer((req, res) => {
@@ -135,7 +135,7 @@ const server = http.createServer((req, res) => {
assert.ok(!removed.includes(keyed[0]) && removed.length === 11, '− takes it off Home');
assert.ok(!removed.includes(keyed[0]) && removed.length === 11, '− takes it off Home');
// Done, then All apps puts it back.
// Done, then All apps puts it back.
await p.evaluate(`[...document.querySelectorAll('.ovh-sec button')].find((b) => b.textContent === 'Done').click(), true`);
await p.evaluate(`[...document.querySelectorAll('.ovh-sec button')].find((b) => b.textContent === 'Done').click(), true`);
- await p.evaluate(`document.querySelector('.ovh-all').click(), true`);
+ await p.evaluate(`document.querySelector('.ovh-allbtn').click(), true`);
assert.ok(await until(p, `!!document.querySelector('.ovh-filter')`), 'the drawer opened');
assert.ok(await until(p, `!!document.querySelector('.ovh-filter')`), 'the drawer opened');
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge.is-add').click(), true`);
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge.is-add').click(), true`);
await p.evaluate(`document.querySelector('.ovh-back').click(), true`);
await p.evaluate(`document.querySelector('.ovh-back').click(), true`);
@@ -199,7 +199,7 @@ const server = http.createServer((req, res) => {
@@ -199,7 +199,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is');
assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-rail').parentElement.querySelector('.ovh-sec small') || {}).textContent`), 'where you were last', 'Jump back in says what it is');
assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');
assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');
- await s.evaluate(`document.querySelector('.ovh-sec button').click(), true`);
+ await s.evaluate(`document.querySelector('.ovh-sec .ovh-edit').click(), true`);
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
@@ -46,11 +46,11 @@ const MEASURE = `(() => {
@@ -46,11 +46,11 @@ const MEASURE = `(() => {
const box = (el) => { const r = el.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right)]; };
const box = (el) => { const r = el.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right)]; };
const name = bar.querySelector('.openvibe-navbar-brand .name');
const name = bar.querySelector('.openvibe-navbar-brand .name');
const parts = [['name', name], ['launcher', bar.querySelector('.ovnav-launch')], ['search', bar.querySelector('#openvibe-search-btn')],
const parts = [['name', name], ['launcher', bar.querySelector('.ovnav-launch')], ['search', bar.querySelector('#openvibe-search-btn')],
- ['balances', bar.querySelector('.ovnav-chip--all')], ['sign in', bar.querySelector('#openvibe-login-btn')],
- ['picture', bar.querySelector('#openvibe-avatar-btn')], ['menu', bar.querySelector('#openvibe-burger')]].filter(([, el]) => vis(el)).map(([k, el]) => [k, box(el)]);
+ ['balances', bar.querySelector('.ovnav-chip--all')], ['menu', bar.querySelector('#openvibe-burger')],
+ ['sign in', bar.querySelector('#openvibe-login-btn')], ['picture', bar.querySelector('#openvibe-avatar-btn')]].filter(([, el]) => vis(el)).map(([k, el]) => [k, box(el)]);
const minis = [...bar.querySelectorAll('.ovnav-chip--all .ovnav-chip-mini')].filter(vis).length;
const minis = [...bar.querySelectorAll('.ovnav-chip--all .ovnav-chip-mini')].filter(vis).length;
const menu = bar.querySelector('#openvibe-burger');
const menu = bar.querySelector('#openvibe-burger');
- const orbMenu = !!(menu && menu.classList.contains('ovnav-orb') && menu.querySelectorAll('.ovo-g .ovo-d').length === 9 && !menu.querySelector('span'));
+ const orbMenu = !!(menu && menu.classList.contains('ovnav-orb') && menu.querySelectorAll('.ovmb .ovmb-app').length === 3 && !!menu.querySelector('.ovmb-bar') && !menu.querySelector('span'));
return { parts, clipped: name.scrollWidth > name.clientWidth + 1, visibleText: name.innerText, minis, width: innerWidth, orbMenu, docW: document.documentElement.scrollWidth };
return { parts, clipped: name.scrollWidth > name.clientWidth + 1, visibleText: name.innerText, minis, width: innerWidth, orbMenu, docW: document.documentElement.scrollWidth };
})()`;
})()`;
@@ -24,13 +24,14 @@ function load(answers) {
@@ -24,13 +24,14 @@ function load(answers) {
global.window = { location: global.location };
global.window = { location: global.location };
try { Object.defineProperty(global, 'navigator', { value: { userAgent: 'test' }, configurable: true }); } catch { /* */ }
try { Object.defineProperty(global, 'navigator', { value: { userAgent: 'test' }, configurable: true }); } catch { /* */ }
const navs = [];
const navs = [];
- const badge = Object.assign(stubElement(), { parentElement: stubElement() });
- const messageButton = stubElement();
+ // The bell takes the count of new messages (4.187.0: there is no Messages button).
+ const bell = [];
+ global.OpenVibeNotifications = { setMessages: (n) => bell.push(n), unread: () => 0, setBellAction() {} };
global.document = {
global.document = {
cookie: '', head: stubElement(), body: stubElement(), title: 'T', visibilityState: 'visible',
cookie: '', head: stubElement(), body: stubElement(), title: 'T', visibilityState: 'visible',
getElementById: () => null, querySelectorAll: () => [],
getElementById: () => null, querySelectorAll: () => [],
- querySelector: (sel) => (sel === '#openvibe-messages-btn b' ? badge : null),
- createElement: (tag) => { const e = stubElement(); e.tag = tag; if (tag === 'nav') { e.querySelector = (sel) => sel === '#openvibe-messages-btn' ? messageButton : stubElement(); navs.push(e); } return e; },
+ querySelector: () => null,
+ createElement: (tag) => { const e = stubElement(); e.tag = tag; if (tag === 'nav') { e.querySelector = () => stubElement(); navs.push(e); } return e; },
addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
};
};
global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };
global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };
@@ -48,7 +49,7 @@ function load(answers) {
@@ -48,7 +49,7 @@ function load(answers) {
return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
};
};
delete require.cache[require.resolve(NAVBAR_PATH)];
delete require.cache[require.resolve(NAVBAR_PATH)];
- return { navbar: require(NAVBAR_PATH), navs, calls, badge, messageButton };
+ return { navbar: require(NAVBAR_PATH), navs, calls, bell };
}
}
const settle = () => new Promise((r) => setTimeout(r, 20));
const settle = () => new Promise((r) => setTimeout(r, 20));
const user = { id: 7, subject: 'usr_7', username: 'maya' };
const user = { id: 7, subject: 'usr_7', username: 'maya' };
@@ -57,37 +58,27 @@ const answers = { coins: { status: 200, body: { balances: { coins: { amount: '12
@@ -57,37 +58,27 @@ const answers = { coins: { status: 200, body: { balances: { coins: { amount: '12
const asked = (calls, re) => calls.filter((u) => re.test(u));
const asked = (calls, re) => calls.filter((u) => re.test(u));
(async () => {
(async () => {
- // Nothing said: the balances and Messages, and one question to Chat for the unread count.
- let { navbar, navs, calls, badge, messageButton } = load(answers);
+ // Nothing said: the balances, and one question to Chat for the unread count, which goes to the bell.
+ let { navbar, navs, calls, bell } = load(answers);
navbar.init(base);
navbar.init(base);
- const dockOpens = [];
- global.OpenVibeDock = { mount() {}, open(options) { dockOpens.push(options); } };
- let prevented = false;
- messageButton.listeners.click({ preventDefault() { prevented = true; }, metaKey: false, ctrlKey: false, shiftKey: false });
- assert.strictEqual(prevented, true, 'signed-in Messages stays on this page');
- assert.deepStrictEqual(dockOpens, [{ tab: 'chats' }], 'the default action opens the Dock Chats tab');
let bar = navs[navs.length - 1].innerHTML;
let bar = navs[navs.length - 1].innerHTML;
- assert.match(bar, /id="openvibe-messages-btn"/, 'Messages is in the bar');
- assert.match(bar, /href="https:\/\/openvibe\.chat\/messages"/, 'and is a real link (a site without the Dock follows it)');
+ assert.doesNotMatch(bar, /openvibe-messages-btn|ovnav-msgs/, 'no Messages button in the bar: the bell counts them');
+ assert.ok(bar.indexOf('id="openvibe-bell-mount"') < bar.indexOf('id="openvibe-avatar-btn"'), 'the bell, then the person: the picture is the last thing in the bar');
assert.match(bar, /data-chip-id="coins"/, 'the OpenCoins chip');
assert.match(bar, /data-chip-id="coins"/, 'the OpenCoins chip');
assert.match(bar, /data-chip-id="vibes"/, 'the Vibes chip');
assert.match(bar, /data-chip-id="vibes"/, 'the Vibes chip');
await settle();
await settle();
assert.deepStrictEqual(asked(calls, /\/api\/dm\/unread$/), ['https://openvibe.chat/api/dm/unread'], 'Chat is asked for the count');
assert.deepStrictEqual(asked(calls, /\/api\/dm\/unread$/), ['https://openvibe.chat/api/dm/unread'], 'Chat is asked for the count');
assert.strictEqual(asked(calls, /\/api\/frame\/wallet\/api\/v1\/me\/wallet$/).length, 1, 'and the Network (its wallet relay) for the balances');
assert.strictEqual(asked(calls, /\/api\/frame\/wallet\/api\/v1\/me\/wallet$/).length, 1, 'and the Network (its wallet relay) for the balances');
- assert.strictEqual(badge.textContent, '3', 'the count shows on the button');
+ assert.deepStrictEqual(bell, [3], 'the count goes to the bell');
navbar.init(base);
navbar.init(base);
await settle();
await settle();
assert.strictEqual(asked(calls, /\/api\/dm\/unread$/).length, 1, 'at most once a minute');
assert.strictEqual(asked(calls, /\/api\/dm\/unread$/).length, 1, 'at most once a minute');
// A site with an HTTP-only session can hand its verified token back from /auth/me.
// A site with an HTTP-only session can hand its verified token back from /auth/me.
- ({ navbar, navs, calls, messageButton } = load({ ...answers, session: { status: 200, body: { user, token: 'session-token' } } }));
+ ({ navbar, navs, calls } = load({ ...answers, session: { status: 200, body: { user, token: 'session-token' } } }));
navbar.init({ service: 'actor', sessionUrl: '/auth/me', notifications: false, wallet: false });
navbar.init({ service: 'actor', sessionUrl: '/auth/me', notifications: false, wallet: false });
await settle();
await settle();
assert.strictEqual(navbar.session.token, 'session-token', 'the navbar receives the site session token');
assert.strictEqual(navbar.session.token, 'session-token', 'the navbar receives the site session token');
- const fromSession = [];
- global.OpenVibeDock = { mount() {}, open(options) { fromSession.push(options); } };
- messageButton.listeners.click({ preventDefault() {}, metaKey: false, ctrlKey: false, shiftKey: false });
- assert.deepStrictEqual(fromSession, [{ tab: 'chats' }], 'the site session opens Chats in place');
({ navbar, calls } = load({ ...answers, session: { status: 200, body: { user } }, sessionToken: { status: 200, body: { token: 'short-access-token' } } }));
({ navbar, calls } = load({ ...answers, session: { status: 200, body: { user } }, sessionToken: { status: 200, body: { token: 'short-access-token' } } }));
navbar.init({ service: 'actor', sessionUrl: '/auth/me', tokenUrl: '/auth/navbar-token', notifications: false, wallet: false });
navbar.init({ service: 'actor', sessionUrl: '/auth/me', tokenUrl: '/auth/navbar-token', notifications: false, wallet: false });
@@ -95,15 +86,6 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));
@@ -95,15 +86,6 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));
assert.strictEqual(navbar.session.token, 'short-access-token', 'HTTP-only sessions use the same-origin token route');
assert.strictEqual(navbar.session.token, 'short-access-token', 'HTTP-only sessions use the same-origin token route');
assert.ok(calls.includes('/auth/navbar-token'), 'the token route is requested after the session identifies the person');
assert.ok(calls.includes('/auth/navbar-token'), 'the token route is requested after the session identifies the person');
- // The button can be pressed while the signed-in page is still resolving its HTTP-only session.
- ({ navbar, messageButton } = load({ ...answers, session: { status: 200, body: { user } }, sessionToken: { status: 200, body: { token: 'late-token' } } }));
- navbar.init({ service: 'actor', user, token: null, sessionUrl: '/auth/me', tokenUrl: '/auth/navbar-token', notifications: false, wallet: false });
- const afterWait = [];
- global.OpenVibeDock = { mount() {}, open(options) { afterWait.push(options); } };
- messageButton.listeners.click({ preventDefault() {}, metaKey: false, ctrlKey: false, shiftKey: false });
- await settle();
- assert.deepStrictEqual(afterWait, [{ tab: 'chats' }], 'a click during session loading opens Chats when the token arrives');
-
// A site that says no.
// A site that says no.
({ navbar, navs, calls } = load(answers));
({ navbar, navs, calls } = load(answers));
navbar.init({ ...base, wallet: false, messages: false });
navbar.init({ ...base, wallet: false, messages: false });
@@ -119,18 +101,19 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));
@@ -119,18 +101,19 @@ const asked = (calls, re) => calls.filter((u) => re.test(u));
assert.doesNotMatch(navs.map((n) => n.innerHTML).join(''), /openvibe-messages-btn|data-chip-id="coins"/);
assert.doesNotMatch(navs.map((n) => n.innerHTML).join(''), /openvibe-messages-btn|data-chip-id="coins"/);
assert.strictEqual(asked(calls, /\/api\/(dm\/unread|coins\/me|frame\/wallet)/).length, 0);
assert.strictEqual(asked(calls, /\/api\/(dm\/unread|coins\/me|frame\/wallet)/).length, 0);
- // A site with its own Messages (Live, Chat) keeps its own count: Chat is not asked by the navbar.
+ // A site with its own Messages (Live, Chat) hands its own count to setMessages: Chat is not asked by the navbar.
({ navbar, navs, calls } = load(answers));
({ navbar, navs, calls } = load(answers));
navbar.init({ ...base, messages: { href: '/messages' } });
navbar.init({ ...base, messages: { href: '/messages' } });
await settle();
await settle();
- assert.match(navs[navs.length - 1].innerHTML, /href="\/messages"/);
assert.strictEqual(asked(calls, /\/api\/dm\/unread$/).length, 0);
assert.strictEqual(asked(calls, /\/api\/dm\/unread$/).length, 0);
- // Chat away: the button stays, without a number.
- ({ navbar, navs, calls, badge } = load({ ...answers, unread: { status: 503, body: {} } }));
+ // Chat away: nothing is counted, and nothing breaks.
+ ({ navbar, navs, calls, bell } = load({ ...answers, unread: { status: 503, body: {} } }));
navbar.init(base);
navbar.init(base);
await settle();
await settle();
- assert.match(navs[navs.length - 1].innerHTML, /id="openvibe-messages-btn"/);
- assert.strictEqual(badge.textContent, '');
+ assert.deepStrictEqual(bell, []);
+ // A site's own count reaches the bell the same way.
+ navbar.setMessages(5);
+ assert.deepStrictEqual(bell, [5]);
console.log('navbar-yours-everywhere: all checks passed');
console.log('navbar-yours-everywhere: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -489,7 +489,8 @@ const created = (subject, over = {}) => ({
@@ -489,7 +489,8 @@ const created = (subject, over = {}) => ({
// clear, the phone sheet; quiet hours); measured 22.56 KB.
// clear, the phone sheet; quiet hours); measured 22.56 KB.
// 24 → 24.5 KB on 2026-11-07 (4.137.0): notification-live.js by its content hash (24.04 KB measured).
// 24 → 24.5 KB on 2026-11-07 (4.137.0): notification-live.js by its content hash (24.04 KB measured).
// 24.5 → 25.5 KB on 2026-11-09 (4.172.0): service faces and readable agent and watch notices (25.24 KB measured).
// 24.5 → 25.5 KB on 2026-11-09 (4.172.0): service faces and readable agent and watch notices (25.24 KB measured).
- assert.ok(size('notification-ui.js') <= 25.5 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 25.5 KB`);
+ // 25.5 → 25.7 KB on 2026-11-10 (4.187.0): new messages are counted on the bell (25.51 KB measured).
+ assert.ok(size('notification-ui.js') <= 25.7 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 25.7 KB`);
console.log('notification live: all checks passed');
console.log('notification live: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,113 +0,0 @@
@@ -1,113 +0,0 @@
-'use strict';
-// The site switcher behind the OpenVibe mark (site-switcher.js, 4.48.0; the owner, 2026-10-30: "clicking the icon …
-// its own drop-down … all the sites, so you can hop between them fast"). In a real headless Chrome (skipped without
-// Chrome or with OV_SKIP_BROWSER=1), with the Frame and the account's sites served through a stubbed fetch.
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const http = require('http');
-
-const ROOT = path.join(__dirname, '..');
-const h = require('../browser-harness');
-if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
- console.log('site-switcher: skipped (no Chrome; set CHROME_BIN)');
- process.exit(0);
-}
-
-const GROUPS = [{ id: 'watch', name: 'Watch and stream' }, { id: 'talk', name: 'Talk and share' }, { id: 'build', name: 'Build on OpenVibe' }];
-const site = (id, name, group, url = `https://openvibe.${id}/`) => ({ id, name, url, icon: id, tagline: `${name} things`, group });
-const nav = [site('live', 'Live', 'watch'), site('video', 'Video', 'watch'), site('chat', 'Chat', 'talk', 'https://127.0.0.1/'),
- site('community', 'Community', 'talk'), site('design', 'Design', 'build'), site('pics', 'Pics', 'watch'), site('mystery', 'Mystery', 'new-group')];
-const FRAME = { nav, groups: GROUPS, soon: [], footer: {} };
-const MINE = { pinned: ['design'], recent: ['chat', 'video', 'live'] };
-
-const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Switcher</title><link rel="icon" href="data:,">
-<script>
- window.__calls = [];
- const realFetch = window.fetch;
- window.fetch = (u, o) => {
- const s = String(u);
- if (s.startsWith('https://openvibe.network/api/frame')) return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) });
- if (s.startsWith('https://openvibe.network/api/themes/me/sites')) {
- window.__calls.push({ url: s, method: (o && o.method) || 'GET', body: o && o.body, auth: o && o.headers && o.headers.Authorization });
- return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(MINE)}) });
- }
- return realFetch(u, o);
- };
-</script>
-<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
-<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'chat', user: { id: 7, username: 'viewer' }, token: 'tok_test', links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false }); });</script>
-</body></html>`;
-const server = http.createServer((req, res) => {
- const u = new URL(req.url, 'http://x');
- if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
- const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
- if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }
- res.writeHead(404); return res.end();
-});
-
-(async () => {
- await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-switcher-'));
- const browser = await h.launch({ tmpDir });
- try {
- const context = await browser.newContext();
- const p = await h.openPage(browser, context, { width: 1280, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
- await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
- await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (localStorage.getItem('ov_frame_v2') && document.getElementById('openvibe-switch-btn')) { clearInterval(t); r(true); } }, 20); })`);
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
- const brand = await p.evaluate(`(() => { const b = document.getElementById('openvibe-switch-btn'); return { tag: b.tagName, popup: b.getAttribute('aria-haspopup'), expanded: b.getAttribute('aria-expanded'), home: !!document.querySelector('.openvibe-navbar-brand .name a[href]') }; })()`);
- assert.deepStrictEqual(brand, { tag: 'BUTTON', popup: 'dialog', expanded: 'false', home: true }, 'the mark opens the switcher; the name stays the link home');
- // The visit waits for an idle moment: wait for it (up to 5 s).
- await p.evaluate(`new Promise((r) => { const t0 = Date.now(); const t = setInterval(() => { if (window.__calls.some((c) => c.method === 'POST') || Date.now() - t0 > 5000) { clearInterval(t); r(true); } }, 50); })`);
- const visits = await p.evaluate(`window.__calls.filter((c) => c.method === 'POST')`);
- assert.strictEqual(visits.length, 1, 'one visit recorded for a signed-in person');
- assert.deepStrictEqual([JSON.parse(visits[0].body).site, visits[0].auth], ['chat', 'Bearer tok_test'], 'this site, with the Network token');
-
- await p.evaluate(`document.getElementById('openvibe-switch-btn').click(), true`);
- await p.evaluate(`new Promise((r) => { const t = setInterval(() => { const h = [...document.querySelectorAll('.ovsw.is-open .ovsw-h')].map((x) => x.firstChild.textContent); if (h.includes('Recent')) { clearInterval(t); r(true); } }, 20); })`);
- const read = () => p.evaluate(`(() => { const box = document.querySelector('.ovsw-panel'); return {
- open: !!document.querySelector('.ovsw.is-open'),
- expanded: document.getElementById('openvibe-switch-btn').getAttribute('aria-expanded'),
- heads: [...box.querySelectorAll('.ovsw-h')].map((x) => x.firstChild.textContent),
- rows: [...box.querySelectorAll('section')].map((s) => [...s.querySelectorAll('.ovsw-t')].map((t) => t.textContent.replace('★', ''))),
- here: [...box.querySelectorAll('.ovsw-t.is-here')].map((t) => t.textContent + '|' + t.getAttribute('aria-current')),
- hrefs: [...box.querySelectorAll('.ovsw-t')].every((t) => /^https?:/.test(t.getAttribute('href'))),
- width: document.documentElement.scrollWidth } })()`);
- let r = await read();
- assert.deepStrictEqual([r.open, r.expanded], [true, 'true']);
- assert.deepStrictEqual(r.heads, ['Recent', 'Pinned', 'Watch and stream', 'Talk and share', 'Build on OpenVibe'], 'recent, pinned, then the frame\'s groups');
- assert.deepStrictEqual(r.rows[0], ['Video', 'Live'], 'recent from the account, newest first, without the page\'s own site');
- assert.deepStrictEqual(r.rows[1], ['Design'], 'pinned from the account');
- assert.deepStrictEqual(r.rows[2], ['Live', 'Video', 'Pics'], 'a group in the frame\'s order');
- assert.deepStrictEqual(r.rows[4], ['Design', 'Mystery'], 'a site in an unknown group joins the last one');
- assert.deepStrictEqual(r.here, ['Chat|page'], 'the page\'s own site is marked by its address');
- assert.ok(r.hrefs, 'every tile is a real link');
-
- // Typing filters every site by name.
- await p.evaluate(`(() => { const q = document.querySelector('.ovsw-top input'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);
- await wait(50);
- r = await read();
- assert.deepStrictEqual(r.rows, [['Pics']], JSON.stringify(r));
-
- // Right-click pins (saved to the account), and Escape closes.
- await p.evaluate(`(() => { const q = document.querySelector('.ovsw-top input'); q.value = ''; q.dispatchEvent(new Event('input')); return true; })()`);
- await wait(50);
- await p.evaluate(`(() => { const t = [...document.querySelectorAll('.ovsw-t')].find((x) => x.textContent === 'Community'); t.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); return true; })()`);
- await wait(50);
- const puts = await p.evaluate(`window.__calls.filter((c) => c.method === 'PUT').map((c) => JSON.parse(c.body))`);
- assert.deepStrictEqual(puts, [{ pinned: ['community', 'design'] }], 'a pin goes to the account');
- r = await read();
- assert.deepStrictEqual(r.rows[1], ['Community', 'Design'], 'and shows at once');
- await p.evaluate(`document.querySelector('.ovsw-panel').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })), true`);
- await wait(50);
- r = await p.evaluate(`({ open: !!document.querySelector('.ovsw.is-open'), expanded: document.getElementById('openvibe-switch-btn').getAttribute('aria-expanded'), overflow: document.documentElement.style.overflow })`);
- assert.deepStrictEqual(r, { open: false, expanded: 'false', overflow: '' }, 'Escape closes it and gives the page its scroll back');
- console.log('site-switcher: all checks passed');
- } finally {
- await browser.close();
- server.close();
- fs.rmSync(tmpDir, { recursive: true, force: true });
- }
-})().catch((e) => { console.error(e); process.exit(1); });
@@ -232,7 +232,7 @@
@@ -232,7 +232,7 @@
// ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),
// ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),
// and window 'ov:theme' { accent, accentLight, bg, mode }.
// and window 'ov:theme' { accent, accentLight, bg, mode }.
// FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand
// FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand
- const FAV_DEFS = '<mask id="ovh" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="#000" stroke-width="24" stroke-linecap="round"/></mask><linearGradient id="ovgo" gradientUnits="userSpaceOnUse" x1="3.09" y1="23" x2="57.09" y2="77"><stop offset="0" stop-color="#87a6ff"/><stop offset="1" stop-color="#4268d6"/></linearGradient><linearGradient id="ovgv" gradientUnits="userSpaceOnUse" x1="0" y1="31.5" x2="0" y2="68.5"><stop offset="0" stop-color="#ae8df9"/><stop offset="1" stop-color="#784fd4"/></linearGradient>';
+ const FAV_DEFS = '<mask id="ovh" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="#000" stroke-width="31" stroke-linecap="round"/></mask><linearGradient id="ovgo" gradientUnits="userSpaceOnUse" x1="3.09" y1="23" x2="57.09" y2="77"><stop offset="0" stop-color="#87a6ff"/><stop offset="1" stop-color="#4268d6"/></linearGradient><linearGradient id="ovgv" gradientUnits="userSpaceOnUse" x1="0" y1="31.5" x2="0" y2="68.5"><stop offset="0" stop-color="#ae8df9"/><stop offset="1" stop-color="#784fd4"/></linearGradient>';
const FAV_BODY = '<circle cx="30.09" cy="50" r="18" fill="none" stroke="url(#ovgo)" stroke-width="18" mask="url(#ovh)"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="url(#ovgv)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>';
const FAV_BODY = '<circle cx="30.09" cy="50" r="18" fill="none" stroke="url(#ovgo)" stroke-width="18" mask="url(#ovh)"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="url(#ovgv)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>';
const FAV_LIGHT = { cx: 30.09, cy: 50, r: 5.58 };
const FAV_LIGHT = { cx: 30.09, cy: 50, r: 5.58 };
const FAV_SITES = { network: ['#60a5fa', ''], live: ['#60a5fa', '#ef4444'], tools: ['#38bdf8', ''], community: ['#34d399', ''], games: ['#a78bfa', '#facc15'], media: ['#fbbf24', ''], space: ['#818cf8', ''], ai: ['#22d3ee', ''] };
const FAV_SITES = { network: ['#60a5fa', ''], live: ['#60a5fa', '#ef4444'], tools: ['#38bdf8', ''], community: ['#34d399', ''], games: ['#a78bfa', '#facc15'], media: ['#fbbf24', ''], space: ['#818cf8', ''], ai: ['#22d3ee', ''] };
@@ -50,7 +50,14 @@
@@ -50,7 +50,14 @@
.ovt-avatar { width: 28px; height: 28px; margin-top: 2px; border-radius: 9px;
.ovt-avatar { width: 28px; height: 28px; margin-top: 2px; border-radius: 9px;
background: radial-gradient(circle at 30% 30%, color-mix(in srgb, var(--ovt-accent) 70%, #fff), var(--ovt-accent) 55%, color-mix(in srgb, var(--ovt-accent) 60%, #000));
background: radial-gradient(circle at 30% 30%, color-mix(in srgb, var(--ovt-accent) 70%, #fff), var(--ovt-accent) 55%, color-mix(in srgb, var(--ovt-accent) 60%, #000));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovt-accent) 35%, transparent); }
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovt-accent) 35%, transparent); }
-.ovt-turn[data-open] .ovt-avatar { animation: ovt-breathe 1.6s ease-in-out infinite; }
+/* While it thinks or writes, the agent's picture shows the thinking sign (the mark's own: three dots rising in turn,
+ openvibe-shared 4.187.0); a face or initials (is-who) keeps breathing instead, so it is never covered. */
+.ovt-avatar { position: relative; }
+.ovt-turn[data-open] .ovt-avatar.is-who { animation: ovt-breathe 1.6s ease-in-out infinite; }
+.ovt-turn[data-open] .ovt-avatar:not(.is-who)::before { content: ""; position: absolute; inset: 0; border-radius: inherit;
+ --d: radial-gradient(circle closest-side, #fff 88%, transparent) no-repeat;
+ background: var(--d) 5px 11.5px / 5px 5px, var(--d) 11.5px 11.5px / 5px 5px, var(--d) 18px 11.5px / 5px 5px;
+ animation: ovt-think 1.2s ease-in-out infinite; }
.ovt-reply { min-width: 0; display: grid; gap: 10px; }
.ovt-reply { min-width: 0; display: grid; gap: 10px; }
/* Grid items size to their content by default: a tool row's long input would widen the page. */
/* Grid items size to their content by default: a tool row's long input would widen the page. */
.ovt-reply > *, .ovt-work, .ovt-w-tool > details { min-width: 0; max-width: 100%; }
.ovt-reply > *, .ovt-work, .ovt-w-tool > details { min-width: 0; max-width: 100%; }
@@ -208,6 +215,12 @@ html[data-ov-motion=off] .ovt-answer.is-revealing .ovt-sw{opacity: 1 !important}
@@ -208,6 +215,12 @@ html[data-ov-motion=off] .ovt-answer.is-revealing .ovt-sw{opacity: 1 !important}
.ovt-c-note:empty { display: none; }
.ovt-c-note:empty { display: none; }
@keyframes ovt-breathe { 50% { opacity: .45; } }
@keyframes ovt-breathe { 50% { opacity: .45; } }
+@keyframes ovt-think {
+ 0%, 60%, 100% { background-position: 5px 11.5px, 11.5px 11.5px, 18px 11.5px; }
+ 15% { background-position: 5px 7.5px, 11.5px 11.5px, 18px 11.5px; }
+ 30% { background-position: 5px 11.5px, 11.5px 7.5px, 18px 11.5px; }
+ 45% { background-position: 5px 11.5px, 11.5px 11.5px, 18px 7.5px; }
+}
@keyframes ovt-rise { from { opacity: 0; transform: translateY(8px); } }
@keyframes ovt-rise { from { opacity: 0; transform: translateY(8px); } }
@keyframes ovt-fade { from { opacity: 0; } }
@keyframes ovt-fade { from { opacity: 0; } }
@keyframes ovt-shimmer { to { background-position: -300% 0; } }
@keyframes ovt-shimmer { to { background-position: -300% 0; } }