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)

OP OpenVibers committed 3 days ago · a951dccb41 · 26 files, +207 −577

* You: your home first, a shorter bar, most used as shortcuts; the Dock opens out of the orb

The owner, 2026-11-10, on the You tab: "Your home should be above Jump back in... the thing at the top a little more
compact... links to whatever the user uses the most". And on the Dock: "that close button should, instead of coming
from that menu, fade and have an animation effect from the top right, the menu button that already exists".

You: the bar with your name is 52px (was 68); your home comes right under it and the AI card; Most used keeps its bar
and its key is now the apps themselves (icon, name, how often: each a link and a full touch target); Jump back in
comes last and says what it is ("where you were last").

Dock on a phone: the sheet fades and grows from the navbar orb; its close orb starts on that orb as the nine dots and
settles into its x in the header while the navbar orb makes the same move underneath; closing runs it back before
the sheet goes. With motion off there is no move. A bar without the orb keeps the old rise from the bottom.

* Merge remote-tracking branch 'origin/main' into you/home-first

* 4.185.0: the changelog, and the lockfile says one version

* The Dock hand-off in fewer bytes; its budget says why it grew

dock.js is 19.80 KB brotli with the hand-off (18.85 before). The styles are tightened and the comments shortened
(the full account is in the CHANGELOG); the budget is 20 KB with a dated note. It runs as the sheet opens, so it
cannot be a sibling loaded later.

* The Dock check waits out the fold-back

On a phone the sheet folds back into the navbar orb before it goes, so "a later tap outside closes it" is true a
moment after the tap. The check polls for it instead of reading it after 150 ms.

* The mark: working is the O as dots going round; thinking is new

The owner, 2026-11-10: "make the O turn into a bunch of little circles... a spinner that has little dots around
it... that actually looks like it is loading... there should be one dedicated for thinking when your agent is
thinking or replying."

busy: the O breaks into eight dots (a dash of no length with a round cap) and three bright ones hop round it, dot
to dot, while the V bobs. think: three dots rise in turn inside the O, the ring quiets, and the V two strokes
glimmer one after the other.

* The Dock's sheet opens and closes as the menu's does and wears its close button; a ring leaves the orb

* Merge remote-tracking branch 'origin/main' into mark-working-dots

# Conflicts:
#	dock.js
#	navbar.js
#	test/dock-size.test.js

* 4.186.0: the changelog and release files

* The conversation kit: the agent's picture shows the thinking sign while it writes

* Merge main (4.186.0)

* 4.187.0: the menu button and the mark the owner chose, the person last in the bar, one menu

The menu button is "Search and apps" (the Arena's M57): 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. One drawing (OpenVibeOrb.svg) 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.

The bar's order is balances, the bell, the menu button, then the person's picture or Sign In. The Messages button is
gone: new messages are counted on the bell and named in its label, and the bell opens the Dock on Chats when messages
are all that is new.

The mark opens the OpenVibe menu on its first tab; site-switcher.js, its panel and its "here" label are deleted.

All apps is a small button beside Edit in the menu's You tab. In the conversation kit an agent's avatar shows three
rising dots while its turn is open.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 21b5ed5..eb5ca72 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 79ed98f..9251f74 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the 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 login
handler 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.
ddiff --git a/STATUS.json b/STATUS.json
iindex e5af3d2..055dea2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/dock.js b/dock.js
iindex 9fe84f9..533ee74 100644
---- a/dock.js
++++ b/dock.js
@@@ -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>
ddiff --git a/files.js b/files.js
iindex 943a5ba..bb08e79 100644
---- a/files.js
++++ b/files.js
@@@ -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)
ddiff --git a/mark.js b/mark.js
iindex a4094fb..69a10c3 100644
---- a/mark.js
++++ b/mark.js
@@@ -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)}`,
ddiff --git a/navbar.js b/navbar.js
iindex 5525ade..41f3e92 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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 the
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 @@
/* 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
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-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'}. */
ddiff --git a/notification-ui.js b/notification-ui.js
iindex 82b61f9..a1e9c24 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -100,6 +100,7 @@
let _pollTimer = null;
let _lastSeenAt = null; // newest created_at we have toasted / seen
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 _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();
ddiff --git a/ov-home.js b/ov-home.js
iindex ceb84ff..9a0c310 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -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-dur
const 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-dur
edit.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-dur
function 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;
}
ddiff --git a/ov-mark.js b/ov-mark.js
iindex 95ea2c5..9fc37f5 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -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');
ddiff --git a/package-lock.json b/package-lock.json
iindex 5e1f587..a362f10 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 25c7397..b8f2a6c 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 60f17ea..0243f66 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.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.
ddiff --git a/search.js b/search.js
iindex 73a421b..e299c37 100644
---- a/search.js
++++ b/search.js
@@@ -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.
ddiff --git a/site-switcher.js b/site-switcher.js
ddeleted file mode 100644
iindex 5bf6813..0000000
---- a/site-switcher.js
++++ /dev/null
@@@ -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);
ddiff --git a/test/brand-face.test.js b/test/brand-face.test.js
iindex caa57ce..0e928d2 100644
---- a/test/brand-face.test.js
++++ b/test/brand-face.test.js
@@@ -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');
ddiff --git a/test/browser-files-complete.test.js b/test/browser-files-complete.test.js
iindex 13fb1f0..271ac72 100644
---- a/test/browser-files-complete.test.js
++++ b/test/browser-files-complete.test.js
@@@ -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');
ddiff --git a/test/mark.test.js b/test/mark.test.js
iindex 2de1dac..e52f841 100644
---- a/test/mark.test.js
++++ b/test/mark.test.js
@@@ -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 });
ddiff --git a/test/menu-close-chrome.test.js b/test/menu-close-chrome.test.js
iindex f5b7d24..7d78d40 100644
---- a/test/menu-close-chrome.test.js
++++ b/test/menu-close-chrome.test.js
@@@ -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');
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex 3026e6b..de66808 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -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`);
ddiff --git a/test/navbar-phone-fit-chrome.test.js b/test/navbar-phone-fit-chrome.test.js
iindex ab9a530..e76d491 100644
---- a/test/navbar-phone-fit-chrome.test.js
++++ b/test/navbar-phone-fit-chrome.test.js
@@@ -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 };
})()`;
ddiff --git a/test/navbar-yours-everywhere.test.js b/test/navbar-yours-everywhere.test.js
iindex 606307e..9bfbada 100644
---- a/test/navbar-yours-everywhere.test.js
++++ b/test/navbar-yours-everywhere.test.js
@@@ -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: '12
const 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); });
ddiff --git a/test/notification-live.test.js b/test/notification-live.test.js
iindex 70e91da..efb53d7 100644
---- a/test/notification-live.test.js
++++ b/test/notification-live.test.js
@@@ -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); });
ddiff --git a/test/site-switcher.test.js b/test/site-switcher.test.js
ddeleted file mode 100644
iindex 00bc0d9..0000000
---- a/test/site-switcher.test.js
++++ /dev/null
@@@ -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); });
ddiff --git a/theme-loader.js b/theme-loader.js
iindex c19d16d..653d867 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -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', ''] };
ddiff --git a/thread.css b/thread.css
iindex 04fcc84..72872ea 100644
---- a/thread.css
++++ b/thread.css
@@@ -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; } }