openvibers/openvibe.shared Commits
4.25.0: the brand bar — new mark, menu orb by the logo, both balances on phones, phone account sheet, auto-hide, messages (#155)
Changes
@@@ -6,6 +6,43 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.25.0 — 2026-10-28++++The brand bar, from the owner's phone on 2026-10-28.++++- **A new mark: the open loop** (`ov-mark.js`, `app-icon.js`): one stroke, an O opened at the top by the V, with the++ site's dot in the opening. It replaces the ring, comet, white V and orbiting dot.++ - **Colour:** from the accent towards violet.++ - **Motion:** it draws itself in, and a short light runs along it.++ - **Light themes:** the dot follows the text colour, so it shows there.++ - **Icons and cards:** app icons (`icons/`), favicons and share cards (`social/`) are redrawn with it.++ - The owner: "the open vibe logo isn't even that great… new ideas… more modern… better effects".++- **The OpenVibe menu's orb, beside the brand** (`navbar.js`). The magnifier on the right is gone (the owner: "the++ little diamond thing… cool animations… next to the logo").++ - **The orb:** nine dots as a diamond. Hovered, a ripple runs from the centre; it waves while the menu loads, and it is++ an × while the menu is open (a second click closes it). Nothing moves at rest.++ - **The menu** (`search.js`) drops from the orb under the bar: a panel on a computer, a sheet as tall as its contents++ on a phone. "On this site" chips carry the place's own icon.++ - **Events:** `search.js` sends `openvibe-menu` events and calls `onClose`.++- **Both balances on phones.** The folded pill shows OpenCoins and Vibes at every width (it showed the first one up++ to 440 px). The bar's padding, gaps, picture and buttons tighten under 400 px; under 350 px the site's word gives++ way to the mark.++- **Vibes beside OpenCoins in the account menu.**++ - **One request:** the navbar asks OpenVibe.Network once, `GET /api/coins/me?with=vibes`, Network#373. Every site's++ connect-src allows the Network and none allowed Billing, so Vibes never showed. An older Network falls back to++ Billing.++ - **Labels:** each balance names itself.++- **The account menu on a phone** is a sheet the width of the screen.++ - **Tiles:** they never leave one alone on a row (five share a row, six make two of three).++ - **Anonymous:** the pill reads whole.++ - **Footer:** its buttons share the row evenly.++- **On a phone the bar steps away** while you read down and comes back on the first scroll up. It never does with a++ menu open or focus inside it. `autoHide: false` keeps it.++- **Messages beside the bell** (`messages: { onOpen, href }`, `setMessages(n)` for the unread count), for a site++ that would otherwise float a bubble over the page (the owner: "this DMs button is like buggy as hell on mobile").++- **Routers get paths.** `onNavigate` (the bar, the account menu and the app bar) receives `/@name?tab=x`, never a++ full address. On Live, "My channel" (`https://openvibe.live/@name`) had opened `/https://openvibe.live/@name`.++## 4.24.4 — 2026-10-28- **`agent-session.js`: no made-up run time.** A finished run mounted without `startedAt` (a replay) showed "0 s"; it@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.24.4 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.25.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.24.4",++ "version": "4.25.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)",@@@ -2,7 +2,7 @@// ═══════════════════════════════════════════════════════════════// openvibe-shared/app-icon — one app icon design for every OpenVibe site.//--// The icon is the OV mark as it appears in the navbar (ring, comet, white V, dot) on the network's++// The icon is the OV mark as it appears in the navbar (the open loop and the site's dot) on the network's// deep navy, tinted per site. No text: a home-screen icon is a few millimetres wide.// svg({ site, maskable }) full-bleed square SVG. maskable keeps the mark inside the 40 % radius// safe zone, so round, squircle and teardrop masks never clip it.@@@ -26,19 +26,18 @@ const SITES = {const of = (site) => SITES[site] || SITES.network;function mark(site, scale) {-- // Drawn in a 48-unit box centred on 0,0 (same geometry as ov-mark.js), then scaled.++ // Drawn in a 48-unit box centred on 0,0 (ov-mark.js's open loop moved by -24,-24), then scaled.const c = of(site);return `<g transform="scale(${scale})">--<circle r="21" fill="url(#glow)"/>--<circle r="18" fill="none" stroke="url(#ring)" stroke-width="4" opacity=".38"/>--<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#ring)" stroke-width="4.4" stroke-linecap="round"/>--<path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="#fff" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>--<circle cx="17.4" cy="4.6" r="3.2" fill="${c.dot}"/></g>`;++<circle r="22" fill="url(#glow)"/>++<path d="M-8.8,-15.2 A17.5,17.5 0 1,0 8.8,-15.2 L0,6.5 Z" fill="none" stroke="url(#ring)" stroke-width="5.6" stroke-linecap="round" stroke-linejoin="round"/>++<circle cy="-16.6" r="3" fill="${c.dot}"/></g>`;}function defs(site) {const [a, b] = of(site).accent;-- return `<defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${a}"/><stop offset="1" stop-color="${b}"/></linearGradient>++ // The stroke runs from the site's accent towards violet, as the navbar's mark does.++ return `<defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${a}"/><stop offset=".65" stop-color="${b}"/><stop offset="1" stop-color="#c026d3"/></linearGradient><radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".34"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient><radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;}@@@ -90,7 +90,7 @@if (e.defaultPrevented) return;if (typeof ctx.config.onNavigate === 'function') {let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }-- if (same && ctx.config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault();++ if (same && ctx.config.onNavigate((() => { try { const u = new URL(a.href, location.href); return `${u.pathname}${u.search}${u.hash}`; } catch { return a.getAttribute('href'); } })(), e) === false) e.preventDefault();}});document.body.appendChild(bar);@@@ -11,7 +11,7 @@ theme, and they fit a phone first.loads only this one.- **`kit.css`**: the look, built on the site's theme tokens.- **`appbar.js`**: the phone tab bar. `navbar.js` loads it only for a site that sets `appBar`.--- **`search.js`**: search across OpenVibe from the navbar's magnifier, loaded on first use.++- **`search.js`**: the OpenVibe menu (search, every site, every tool) behind the orb beside the brand, loaded on first use.- **`loading.js`**: the one loading look (an overlay over the thing's own picture) and skeleton rows.The package is MIT. Every site serves its own pinned copy at `/shared/kit.css`, `/shared/kit.js`, `/shared/kit-web.js` and@@@ -45,7 +45,7 @@ The same day, a sweep measured the front page of all 38 live sites at 390 px wid```┌──────────────────────────────┐--│ navbar (sticky) │ OpenVibe Frame: brand, launcher, bell, account++│ navbar (sticky) │ OpenVibe Frame: brand + menu orb, balances, bell, account├──────────────────────────────┤│ ‹ Title ⓘ ••• │ kit.head: back, title, one muted line, icon actions ┐ sticky under│ ( Look ) Privacy Sound … │ kit.tabs: the page's parts, one at a time ┘ the navbar@@@ -210,10 +210,11 @@ The rules since 4.22.0:icons make the phone app bar (`appBar: true`).2. **The rest is `more: true`.** Those links fold into one "More" on computers and are listed in the phone drawer:guides, sources, "how it works", the API.--3. **Search is the magnifier**, not a link: `search: { owner, label, page }` points it at the site's own results and-- search page.++3. **Search is the orb beside the brand**, not a link: `search: { owner, label, page }` points it at the site's own++ results and search page.4. **"What shipped" lives in the footer** (Updates), which every site has.--5. **The account menu holds the person's things** (dashboard, settings, admin), and the launcher holds the other sites.++5. **The account menu holds the person's things** (dashboard, settings, admin), and the OpenVibe menu holds the other++ sites.The bar no longer repeats them (`networkLinks` brings the strip back for a site that really wants it).```js@@@ -229,14 +230,40 @@ OpenVibeNavbar.init({ service: 'food', links: [**On a phone** the bar keeps the site's name readable and never stacks one control on another (4.22.1):- **The name:** the site's word alone ("Games", not "OpenVibe.Games") up to 480 px. When there is still no room, itends with an ellipsis and is never drawn under the buttons.--- **Signed in:**-- - the balances fold into one pill showing the first balance, up to 440 px (a tap lists them all);-- - the launcher steps into the menu, whose "All sites" opens it.--- **Everyone under 375 px:** the launcher steps into the menu.--- **Spacing:** the right-hand buttons sit closer together under 400 px.++- **Signed in:** the balances fold into one pill under 560 px that always shows **both**, OpenCoins and Vibes (4.25.0;++ a tap lists them in full). Under 440 px the pill tightens; under 350 px the site's word gives way to the mark so the++ balances keep their icons.++- **The menu's orb** stays beside the brand at every width.++- **Spacing:** under 400 px the bar's padding, gaps, picture and buttons tighten.++- **Reading down a phone** (under 760 px), the bar steps away and comes back on the first scroll up. It never does++ with one of its menus open or focus inside it. `autoHide: false` keeps it.++- **The account menu** is a sheet the width of the screen under 560 px. Its tiles never leave one alone on a row.++++**Messages** (4.25.0): a site with messages puts them beside the bell instead of a bubble over the page.++++```js++OpenVibeNavbar.init({ …, messages: { onOpen: () => openInbox(), href: '/messages' } }) // href without onOpen: a link++OpenVibeNavbar.setMessages(3) // the unread count (0 hides it)++```++++**Routers get paths** (4.25.0): `onNavigate(path, event)` receives `/@name?tab=x#y` for a link on this site, never a++full address. The account menu's "My channel" is `https://openvibe.live/@name` on every site, and Live's router once++put it under `/`.`test/navbar-phone-fit-chrome.test.js` measures the bar from 320 to 430 px, signed in and out.++## The mark++++`ov-mark.js` draws the OpenVibe mark (4.25.0, the "open loop"): one stroke, an O opened at the top by the V, so it++reads OV and "open" at once. The site's dot sits in the opening.++- **Colour:** the stroke runs from the site's accent towards violet (`--ovm-to`).++- **Motion:**++ - it draws itself in on a visit's first view, and a short light runs along it while it moves;++ - otherwise it rests (the motion budget).++- **Variants:** each site keeps its own rhythm (`data-variant`). Live's dot is red and on air.++- **App icons, favicons and share cards** draw the same loop (`app-icon.js`, `scripts/build-icons.js`,++ `scripts/build-social-cards.js`).++## The app bar```js@@@ -258,8 +285,15 @@ the site's main verb (go live, post, ask). Settings and the rest live in More.## The OpenVibe menu: search, every site, every tool--One control on every site: the navbar's magnifier (or `/`, or Ctrl+K) opens the OpenVibe menu. It used to be two--panels doing the same job, the launcher's dots (every site) and the magnifier (search). The owner, 2026-10-28:++One control on every site: the orb beside the brand (or `/`, or Ctrl+K) opens the OpenVibe menu. It used to be two++panels doing the same job, the launcher's dots (every site) and the magnifier (search).++++**The orb** (4.25.0; the owner: "the little diamond thing… an icon that has cool animations and effects… next to the++logo"): nine dots set as a diamond, the centre in the accent.++- **Hovered:** a ripple runs out from the centre and the dots take the accent's colours.++- **While the menu loads:** the dots wave.++- **While it is open:** the diamond turns into an × that closes it.++- **At rest it doesn't move**, and reduced motion turns all of it off. The owner, 2026-10-28:"combine… a better hierarchy… use this user's history… autocomplete… maybe use tabs". The navbar loads `search.js` thefirst time someone asks, so a page nobody searches pays nothing (and the navbar itself got 15 KB lighter).@@@ -284,7 +318,9 @@ completion shows faintly behind the text; Tab or → takes it.**Keys:** arrows move, Enter opens, Escape closes.--**Layout:** a phone gets the whole screen; a computer gets a panel near the top, with the keys listed at its foot.++**Layout:** opened from the orb, the menu drops from it under the bar. On a computer it is a panel from the orb's side, with++the keys listed at its foot. On a phone it is a sheet as tall as its contents. Opened any other way, it is a centred++panel (a phone gets the whole screen).```jsOpenVibeNavbar.init({ …, search: { owner: 'live', label: 'OpenVibe.Live', page: '/search' } }) // or search: falseBBinary files a/icons/ai-192.png and b/icons/ai-192.png differBBinary files a/icons/ai-512.png and b/icons/ai-512.png differBBinary files a/icons/ai-maskable-192.png and b/icons/ai-maskable-192.png differBBinary files a/icons/ai-maskable-512.png and b/icons/ai-maskable-512.png differBBinary files a/icons/community-192.png and b/icons/community-192.png differBBinary files a/icons/community-512.png and b/icons/community-512.png differBBinary files a/icons/community-maskable-192.png and b/icons/community-maskable-192.png differBBinary files a/icons/community-maskable-512.png and b/icons/community-maskable-512.png differBBinary files a/icons/games-192.png and b/icons/games-192.png differBBinary files a/icons/games-512.png and b/icons/games-512.png differBBinary files a/icons/games-maskable-192.png and b/icons/games-maskable-192.png differBBinary files a/icons/games-maskable-512.png and b/icons/games-maskable-512.png differBBinary files a/icons/live-192.png and b/icons/live-192.png differBBinary files a/icons/live-512.png and b/icons/live-512.png differBBinary files a/icons/live-maskable-192.png and b/icons/live-maskable-192.png differBBinary files a/icons/live-maskable-512.png and b/icons/live-maskable-512.png differBBinary files a/icons/media-192.png and b/icons/media-192.png differBBinary files a/icons/media-512.png and b/icons/media-512.png differBBinary files a/icons/media-maskable-192.png and b/icons/media-maskable-192.png differBBinary files a/icons/media-maskable-512.png and b/icons/media-maskable-512.png differBBinary files a/icons/network-192.png and b/icons/network-192.png differBBinary files a/icons/network-512.png and b/icons/network-512.png differBBinary files a/icons/network-maskable-192.png and b/icons/network-maskable-192.png differBBinary files a/icons/network-maskable-512.png and b/icons/network-maskable-512.png differ@@@ -1,7 +1,7 @@{"actor": {"name": "OpenVibe.Actor",-- "description": "Your agent for everything",++ "description": "Agents that work while you are away","icon": "network"},"ai": {@@@ -16,17 +16,17 @@},"bot": {"name": "OpenVibe.Bot",-- "description": "An open control panel for robots",++ "description": "Drive any robot from your browser","icon": "network"},"chat": {"name": "OpenVibe.Chat",-- "description": "One chat for the network",++ "description": "Chat with Vibe, friends and groups","icon": "network"},"codes": {"name": "OpenVibe.Codes",-- "description": "Code with any agent",++ "description": "Build and fix apps with AI","icon": "network"},"community": {@@@ -56,7 +56,7 @@},"games": {"name": "OpenVibe.Games",-- "description": "Browser games",++ "description": "Browser games, and games you make","icon": "games"},"help": {@@@ -76,7 +76,7 @@},"live": {"name": "OpenVibe.Live",-- "description": "Streams, clips and chat",++ "description": "Go live from your browser","icon": "live"},"media": {@@@ -86,7 +86,7 @@},"media-hub": {"name": "OpenVibe.Video",-- "description": "Past streams, clips and your drive",++ "description": "Videos, pictures and your files","icon": "media"},"network": {@@@ -106,7 +106,7 @@},"play": {"name": "OpenVibe.Play",-- "description": "Achievements and leaderboards for every OpenVibe game.",++ "description": "Achievements, leaderboards and cloud saves for every game.","icon": "network"},"quest": {@@@ -116,7 +116,7 @@},"rent": {"name": "OpenVibe.Rent",-- "description": "Find a place, or rent yours out",++ "description": "Every rental site, one search","icon": "network"},"reviews": {@@@ -126,7 +126,7 @@},"run": {"name": "OpenVibe.Run",-- "description": "Run a job on a node that can",++ "description": "Run jobs on machines that can.","icon": "network"},"search": {@@@ -141,12 +141,12 @@},"space": {"name": "OpenVibe.Space",-- "description": "Static sites, hosted by OpenVibe",++ "description": "Make a space: an app on your account","icon": "space"},"tips": {"name": "OpenVibe.Tips",-- "description": "Support creators",++ "description": "Tips, paid messages and alerts for any stream","icon": "network"},"tools": {@@@ -161,7 +161,7 @@},"vip": {"name": "OpenVibe.VIP",-- "description": "Memberships and perks",++ "description": "Memberships for creators and their software","icon": "network"},"watch": {BBinary files a/icons/space-192.png and b/icons/space-192.png differBBinary files a/icons/space-512.png and b/icons/space-512.png differBBinary files a/icons/space-maskable-192.png and b/icons/space-maskable-192.png differBBinary files a/icons/space-maskable-512.png and b/icons/space-maskable-512.png differBBinary files a/icons/tools-192.png and b/icons/tools-192.png differBBinary files a/icons/tools-512.png and b/icons/tools-512.png differBBinary files a/icons/tools-maskable-192.png and b/icons/tools-maskable-192.png differBBinary files a/icons/tools-maskable-512.png and b/icons/tools-maskable-512.png differ@@@ -30,7 +30,12 @@/* At the very top the bar fades into the page (no background, no border); a scroll brings the frosted bar back.A page whose hero runs under the bar opts in with body.ov-nav-overlay: the bar then sits over the hero, with asoft scrim for the text. ovnav-top is kept by the navbar itself on every render and scroll. */-- .openvibe-navbar { transition: background-color .45s cubic-bezier(.2,.7,.2,1), border-color .45s cubic-bezier(.2,.7,.2,1), box-shadow .45s cubic-bezier(.2,.7,.2,1), -webkit-backdrop-filter .45s, backdrop-filter .45s; }++ .openvibe-navbar { transition: background-color .45s cubic-bezier(.2,.7,.2,1), border-color .45s cubic-bezier(.2,.7,.2,1), box-shadow .45s cubic-bezier(.2,.7,.2,1), -webkit-backdrop-filter .45s, backdrop-filter .45s, transform .32s cubic-bezier(.2,.7,.2,1); }++ /* On a phone the bar steps away while you read down the page and comes back the moment you scroll up (4.25.0;++ the owner, 2026-10-28: the bar's at-the-top effect "doesn't work on mobile… the whole thing needs to be++ improved"). Never while one of its menus is open. A site turns it off with autoHide: false. */++ .openvibe-navbar.ovnav-hide { transform: translateY(calc(-100% - 10px)); box-shadow: none; }++ #navbar-mount:has(> .ovnav-hide) { pointer-events: none; }.openvibe-navbar::before { content: ""; position: absolute; inset: 0 0 -28px; pointer-events: none; z-index: -1; opacity: 0; transition: opacity .45s cubic-bezier(.2,.7,.2,1); background: linear-gradient(180deg, rgba(4,7,14,.62), rgba(4,7,14,.28) 55%, rgba(4,7,14,0)); }.openvibe-navbar:not(.ovnav-top) { background-color: color-mix(in srgb, var(--bg-secondary, #252530) 84%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px); box-shadow: 0 12px 32px -14px rgba(0,0,0,.6); }.openvibe-navbar.ovnav-top { background-color: transparent; border-bottom-color: transparent; box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; }@@@ -151,8 +156,14 @@.openvibe-navbar .openvibe-navbar-brand { flex: 0 1 auto; }.openvibe-navbar .openvibe-navbar-brand a.flame { flex: none; }.openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }-- @media (max-width: 440px) { .openvibe-navbar .ovnav-chip--all .ovnav-chip-mini ~ .ovnav-chip-mini { display: none; } }-- @media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 2px; } }++ /* Both balances on every phone (the owner, 2026-10-28: "you can only see one… use the full space"): the pill++ tightens instead of folding to one. */++ @media (max-width: 440px) { .ovnav-chip-mini { padding: 0 5px; gap: 3px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 12px; } .ovnav-chip-mini .ovnav-ic { width: 13px; height: 13px; } }++ @media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 1px; } .ovnav-chip-mini { padding: 0 3px; gap: 2px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 11.5px; letter-spacing: -.2px; } .openvibe-navbar .openvibe-navbar-brand { gap: 4px; }++ .openvibe-navbar { padding-left: 8px; padding-right: 6px; gap: 2px; } .openvibe-navbar .ovnav-burger { width: 34px; } .openvibe-navbar .openvibe-navbar-avatar { width: 32px; height: 32px; box-sizing: border-box; }++ .openvibe-navbar .openvibe-navbar-brand .name { font-size: 14px; letter-spacing: -.3px; } .openvibe-navbar .openvibe-navbar-brand a.flame { width: 28px; height: 28px; } }++ /* Under 350px the mark alone names the site, so both balances keep their icons (a number alone says nothing). */++ @media (max-width: 350px) { .openvibe-navbar .openvibe-navbar-brand .name { display: none; } }.openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */@@@ -204,8 +215,44 @@.openvibe-navbar-spacer { flex: 1; }.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }-- .openvibe-navbar .ovnav-search { all: unset; box-sizing: border-box; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; cursor: pointer; flex: none; }-- .openvibe-navbar .ovnav-search:hover, .openvibe-navbar .ovnav-search:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }++ /* 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. */++ .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; }++ .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; }++ .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; } }++ @media (prefers-reduced-motion: reduce) { .ovnav-orb .ovo-g, .ovnav-orb .ovo-d, .ovnav-orb::before { transition: none; animation: none !important; } }++ html[data-ov-motion="reduced"] .ovnav-orb .ovo-d { animation: none !important; }++ .openvibe-navbar .openvibe-navbar-brand .ovnav-orb { margin-left: 1px; }++ @media (max-width: 400px) { .openvibe-navbar .ovnav-orb { width: 28px; height: 32px; } }++ /* 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: 19px; height: 19px; }++ .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;@@@ -270,7 +317,7 @@color: var(--text-primary, #e0e0e0); text-decoration: none; transition: background .12s;}.openvibe-navbar-dropdown-accounts .account-item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-- .openvibe-navbar-dropdown-accounts .account-item.ud-anon { padding-left: 9px; color: var(--text-secondary, #a8b3c4); }++ .openvibe-navbar-dropdown-accounts .account-item.ud-anon { padding-left: 9px; max-width: none; color: var(--text-secondary, #a8b3c4); }.openvibe-navbar-dropdown-accounts .account-item:hover, .openvibe-navbar-dropdown-accounts .account-item:focus-visible { background: var(--bg-hover, #2f2f3d); }.openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }.openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; }@@@ -300,9 +347,26 @@.openvibe-navbar-dropdown-menu .ud-more summary:hover { background: var(--bg-hover, #2f2f3d); }.openvibe-navbar-dropdown-menu .ud-more[open] summary .icon { transform: rotate(180deg); }.openvibe-navbar-dropdown-menu .ud-more .ud-n { margin-left: auto; font-size: 11.5px; color: var(--text-muted, #7d8aa0); }++ /* The tiles never leave one alone on a row: five or fewer share one row, six make two of three, seven and++ eight two of four (the owner's phone, 2026-10-28: an Admin tile on a row of its own). */++ .openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(3):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); }++ .openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(5):last-child) { grid-template-columns: repeat(5, minmax(0, 1fr)); }++ .openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(6):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); }++ /* The balances in the header: OpenCoins and Vibes side by side, each a coloured icon, a number and its name. */++ .openvibe-navbar-dropdown-header .ud-wallet a small { font-weight: 600; opacity: .75; font-size: 10.5px; }++ /* On a phone the menu is a sheet the width of the screen under the bar, not a narrow card off to the right. */++ @media (max-width: 560px) {++ .openvibe-navbar .openvibe-navbar-dropdown { left: 8px; right: 8px; width: auto; max-width: none; border-radius: 18px; }++ .openvibe-navbar-dropdown-header { padding: 14px 10px 12px 14px; }++ .openvibe-navbar-dropdown-header img { width: 48px; height: 48px; }++ .openvibe-navbar-dropdown-header .info .name { font-size: 16px; }++ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile { min-height: 72px; font-size: 12px; }++ .openvibe-navbar-dropdown-accounts .account-item { max-width: 46vw; }++ }/* One row at the foot: Light, Dark, Auto, text size, motion, themes, and Sign out. */.openvibe-navbar-dropdown-menu .ud-foot { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 4px -8px -6px; padding: 8px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent); }.openvibe-navbar-dropdown-menu .ud-foot .ud-ib { width: auto; min-width: 34px; height: 34px; padding: 0 8px; justify-content: center; gap: 3px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); }++ .openvibe-navbar-dropdown-menu .ud-foot > .ud-ib { flex: 1 1 0; }.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .icon { width: 18px; color: inherit; }.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val { margin: 0; font-size: 11px; font-weight: 700; color: inherit; }.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val:empty { display: none; }@@@ -373,12 +437,14 @@'fa-text-height': [576, 'M64 139V107V139V107H128V427H96Q82 427 73 436Q64 445 64 459Q64 473 73 482Q82 491 96 491H224Q238 491 247 482Q256 473 256 459Q256 445 247 436Q238 427 224 427H192V107H256V139Q256 153 265 162Q274 171 288 171Q302 171 311 162Q320 153 320 139V91Q319 71 306 57Q292 44 272 43H160H48Q28 44 14 57Q1 71 0 91V139Q0 153 9 162Q18 171 32 171Q46 171 55 162Q64 153 64 139ZM503 52Q493 43 480 43Q467 43 457 52L393 116Q379 132 386 151Q395 170 416 171H448V363H416Q395 364 386 383Q379 402 393 418L457 482Q467 491 480 491Q493 491 503 482L567 418Q581 402 574 383Q565 364 544 363H512V171H544Q565 170 574 151Q581 132 567 116L503 52Z'],'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],++ 'fa-wallet': [512, 'M64 43Q37 44 19 62Q1 80 0 107V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V203Q511 176 493 158Q475 140 448 139H80Q65 138 64 123Q65 108 80 107H448Q462 107 471 98Q480 89 480 75Q480 61 471 52Q462 43 448 43H64ZM416 283Q430 283 439 292Q448 301 448 315Q448 329 439 338Q430 347 416 347Q402 347 393 338Q384 329 384 315Q384 301 393 292Q402 283 416 283Z'],'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],'fa-chevron-down': [512, 'M233 418Q243 427 256 427Q269 427 279 418L471 226Q480 216 480 203Q480 190 471 180Q461 171 448 171Q435 171 425 180L256 350L87 180Q77 171 64 171Q51 171 41 180Q32 190 32 203Q32 216 41 226L233 418Z'],'fa-gear': [512, 'M496 178Q500 192 490 202L446 242Q448 254 448 267Q448 280 446 292L490 332Q500 342 496 356Q489 374 480 391L475 399Q465 415 453 430Q443 441 429 437L373 419Q353 435 329 444L317 502Q313 516 298 519Q278 523 256 523Q234 523 213 519Q199 516 195 502L183 444Q159 435 139 419L83 437Q69 441 59 430Q46 415 37 399L32 391Q23 374 16 357Q12 342 22 332L66 293Q64 280 64 267Q64 254 66 242L22 202Q12 192 16 178Q23 160 32 143L37 135Q46 119 59 104Q69 93 83 97L139 115Q159 99 183 89L195 32Q199 18 214 15Q234 11 256 11Q278 11 299 15Q313 18 317 32L329 89Q353 99 373 115L429 97Q443 93 453 104Q466 119 476 135L480 143Q489 160 496 178ZM256 347Q301 346 325 307Q347 267 325 227Q301 188 256 187Q211 188 187 227Q165 267 187 307Q211 346 256 347Z'],'fa-circle-half-stroke': [512, 'M448 267Q446 185 392 131Q338 77 256 75V459Q338 457 392 403Q446 349 448 267ZM0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267Z'],'fa-moon': [384, 'M224 43Q161 44 111 74Q60 103 30 154Q1 204 0 267Q1 330 31 380Q60 431 111 460Q161 490 224 491Q317 489 379 428Q387 419 382 409Q377 399 365 400Q351 403 335 403Q261 401 211 351Q162 302 160 227Q160 177 184 137Q209 97 249 74Q259 68 257 56Q254 46 242 44Q233 43 223 43Z'],'fa-sun': [512, 'M362 12Q369 16 371 24L391 132L499 152Q507 154 511 161Q514 169 509 177L447 267L509 357Q514 365 511 373Q507 380 499 382L391 402L371 510Q369 518 362 522Q354 525 346 520L256 458L166 520Q158 525 151 522Q143 518 141 510L121 402L13 382Q5 380 1 373Q-2 365 3 357L65 267L3 177Q-2 169 1 162Q5 154 13 152L121 132L141 24Q143 16 151 12Q158 9 166 14L256 76L346 14Q354 9 362 12ZM160 267Q160 241 173 219Q186 197 208 184Q231 171 256 171Q281 171 304 184Q326 197 339 219Q352 241 352 267Q352 293 339 315Q326 337 304 350Q281 363 256 363Q231 363 208 350Q186 337 173 315Q160 293 160 267ZM384 267Q384 232 367 203Q350 174 320 156Q290 139 256 139Q222 139 192 156Q162 174 145 203Q128 232 128 267Q128 302 145 331Q162 360 192 378Q222 395 256 395Q290 395 320 378Q350 360 367 331Q384 302 384 267Z'],++ 'fa-comment-dots': [512, 'M256 459Q328 458 385 431Q443 403 477 356Q511 309 512 251Q511 193 477 146Q443 99 385 71Q328 44 256 43Q184 44 127 71Q69 99 35 146Q1 193 0 251Q1 320 48 372Q43 409 26 435Q18 449 11 456Q8 460 6 462Q6 463 5 463Q5 463 5 464Q-2 471 1 481Q6 491 16 491Q60 489 98 472Q133 456 152 441Q200 459 256 459ZM128 219Q142 219 151 228Q160 237 160 251Q160 265 151 274Q142 283 128 283Q114 283 105 274Q96 265 96 251Q96 237 105 228Q114 219 128 219ZM256 219Q270 219 279 228Q288 237 288 251Q288 265 279 274Q270 283 256 283Q242 283 233 274Q224 265 224 251Q224 237 233 228Q242 219 256 219ZM352 251Q352 237 361 228Q370 219 384 219Q398 219 407 228Q416 237 416 251Q416 265 407 274Q398 283 384 283Q370 283 361 274Q352 265 352 251Z'],'fa-brain': [512, 'M184 11Q208 12 224 27Q239 43 240 67V467Q239 491 224 507Q208 522 184 523Q162 523 146 509Q131 495 128 473Q120 475 112 475Q85 474 67 456Q49 438 48 411Q48 400 52 390Q29 381 14 361Q0 341 0 315Q0 291 13 272Q25 253 46 243Q32 226 32 203Q32 180 46 163Q60 146 82 140Q80 132 80 123Q80 100 94 83Q107 67 128 61Q131 39 146 25Q162 11 184 11ZM328 11Q350 11 366 25Q381 39 384 61Q405 67 418 83Q432 100 432 123Q432 132 430 140Q452 146 466 163Q480 180 480 203Q480 226 466 243Q487 253 499 272Q512 291 512 315Q512 341 498 361Q483 381 460 390Q464 400 464 411Q463 438 445 456Q427 474 400 475Q392 475 384 473Q381 495 366 509Q350 523 328 523Q304 522 288 507Q273 491 272 467V67Q273 43 288 27Q304 12 328 11Z'],};// Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives).@@@ -386,7 +452,7 @@320: 'fa-chevron-right',384: 'fa-xmark fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',448: 'fa-book fa-hand-fist fa-user fa-bars fa-arrow-right fa-check fa-tag fa-bolt fa-font fa-lock fa-utensils',-- 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-magnifying-glass fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',++ 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-arrow-up-right-from-square fa-magnifying-glass fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',576: 'fa-house fa-id-card fa-video fa-star fa-tower-cell fa-diagram-project fa-money-bill-wave fa-crown fa-ticket fa-map',640: 'fa-comments fa-gamepad fa-link fa-network-wired fa-people-arrows fa-shirt fa-code fa-robot fa-photo-film fa-wave-square fa-user-shield',};@@@ -599,9 +665,18 @@return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}"><a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a><span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>++ ${_config.search === false ? '' : orbHTML()}</div>`;}++ // 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 orbHTML() {++ 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 `<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"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><g class="ovo-g">${dots}</g></svg></button>`;++ }++// ── Frame data (https://openvibe.network/api/frame) ─────────────────────────────// Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30@@@ -671,6 +746,23 @@}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. */++ 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 (typeof m.onOpen !== 'function' || e.metaKey || e.ctrlKey || e.shiftKey) return;++ e.preventDefault(); try { m.onOpen(e); } catch { /* the link still works */ }++ });++ }++function chipsAllHTML(chips) {const list = (Array.isArray(chips) ? chips : []).filter((c) => c && !c.hidden);if (list.length < 2) return '';@@@ -723,6 +815,13 @@});}++ /** A link on this site as the path a router takes (/@name?tab=x#y), or null for another site. The account menu's++ * links are full addresses (https://openvibe.live/@name on every site), and a router handed one put it under '/'++ * (Live, 2026-10-28: "My channel" opened /https://openvibe.live/@name). */++ function sitePath(a) {++ try { const u = new URL(a.href, location.href); return u.origin === location.origin ? `${u.pathname}${u.search}${u.hash}` : null; } catch { return null; }++ }++/** Link behaviour: per-link onClick, in-app navigation (onNavigate), dropdowns, the mobile drawer. */function bindLinks(nav, links) {const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l]));@@@ -738,8 +837,8 @@if (dd && matchMedia('(hover: none)').matches && !dd.classList.contains('open')) { e.preventDefault(); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); dd.classList.add('open'); placeDd(dd); return; }if (item && typeof item.onClick === 'function') { const r = item.onClick(e); if (r === false) e.preventDefault(); closeDrawer(); if (e.defaultPrevented) return; }if (typeof _config.onNavigate === 'function' && a.target !== '_blank') {-- let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }-- if (same && _config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault();++ const path = sitePath(a);++ if (path != null && _config.onNavigate(path, e) === false) e.preventDefault();}closeDrawer();});@@@ -812,16 +911,18 @@if (_walletLoaded || !_config.token || inBar || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true;const auth = { Authorization: `Bearer ${_config.token}` };const chip = (id, html) => { const el = dropdown.querySelector(id); if (el) { el.innerHTML = html; el.hidden = false; } };-- const coins = fetch(`${_config.apiBase}/api/coins/me`, { headers: auth, credentials: 'include' }).then(async (r) => {-- if (!r.ok) return; const j = await r.json(); const bal = Number(j.balance ?? (j.wallet && j.wallet.balance));-- if (isFinite(bal)) chip('#openvibe-wallet', `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()}</a>`);-- });-- // Vibes cross-site (the spendable balance Billing keeps). Billing answers 409 until it is the ledger: no chip then.-- const vibes = fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' }).then(async (r) => {-- if (!r.ok) return; const v = Number((await r.json()).credit);-- if (isFinite(v)) chip('#openvibe-vibes', `<a href="https://openvibe.live/#vibes" title="Your Vibes">${v.toLocaleString()} Vibes</a>`);-- });-- await Promise.all([coins.catch(() => {}), vibes.catch(() => {})]); // both chips are optional++ const showVibes = (v) => { if (isFinite(v)) chip('#openvibe-vibes', `<a href="https://openvibe.live/#vibes" title="Your Vibes">${navIcon('fa-wallet')} ${v.toLocaleString()} <small>Vibes</small></a>`); };++ // Both balances from the Network in one request (it asks Billing): every site's connect-src allows++ // openvibe.network and none allowed billing.openvibe.network, so Vibes never showed (2026-10-28).++ const both = await fetch(`${_config.apiBase}/api/coins/me?with=vibes`, { headers: auth, credentials: 'include' }).then((r) => (r.ok ? r.json() : null)).catch(() => null);++ if (both) {++ const bal = Number(both.balance ?? (both.wallet && both.wallet.balance));++ if (isFinite(bal)) chip('#openvibe-wallet', `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()} <small>OpenCoins</small></a>`);++ }++ if (both && 'vibes' in both) { if (both.vibes != null) showVibes(Number(both.vibes)); return; }++ // An older Network: Billing itself (409 until it is the ledger: no chip then).++ await fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })++ .then(async (r) => { if (r.ok) showVibes(Number((await r.json()).credit)); }).catch(() => {});}/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */@@@ -1486,11 +1587,12 @@OpenVibeNavbar.setChip(id, saved[id], { hidden: false });};const num = (v) => (isFinite(v) ? v : null);-- const coins = fetch(`${_config.apiBase}/api/coins/me`, { headers: auth, credentials: 'include' })-- .then(async (r) => { if (r.ok) { const j = await r.json(); put('coins', num(Number(j.balance ?? (j.wallet && j.wallet.balance)))); } });-- // Billing answers 409 until it is the ledger: no Vibes chip then.-- const vibes = fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })-- .then(async (r) => { if (r.ok) put('vibes', num(Number((await r.json()).credit))); else if (r.status === 409) put('vibes', null); });++ // Both from the Network (it asks Billing); an older Network without vibes: Billing itself (409 = not the ledger).++ let vibesKnown = false;++ const coins = fetch(`${_config.apiBase}/api/coins/me?with=vibes`, { headers: auth, credentials: 'include' })++ .then(async (r) => { if (!r.ok) return; const j = await r.json(); put('coins', num(Number(j.balance ?? (j.wallet && j.wallet.balance)))); if ('vibes' in j && j.vibes != null) { vibesKnown = true; put('vibes', num(Number(j.vibes))); } });++ const vibes = coins.catch(() => {}).then(() => (vibesKnown ? null : fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })++ .then(async (r) => { if (r.ok) put('vibes', num(Number((await r.json()).credit))); else if (r.status === 409) put('vibes', null); })));Promise.all([coins.catch(() => {}), vibes.catch(() => {})]).then(() => {try { localStorage.setItem(walletKey(u), JSON.stringify(saved)); } catch { /* */ }// A chip that appeared or went away changes the folded phone chip: draw the bar once more.@@@ -1535,8 +1637,8 @@</div><div class="openvibe-navbar-spacer"></div><div class="openvibe-navbar-right">-- ${_config.search === false ? '' : '<button type="button" class="ovnav-search" id="openvibe-search-btn" aria-label="Search and every OpenVibe site (/)" title="Search and every OpenVibe site (/)"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg></button>'}${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}++ ${u && !isAnon && _config.messages ? messagesHTML() : ''}<div id="openvibe-bell-mount"></div>${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>`}@@@ -1546,6 +1648,7 @@`;if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));bindLinks(nav, links);++ bindMessages(nav);hookSearch(nav);bindChips(nav);bindChipsAll(nav);@@@ -1604,7 +1707,7 @@</div>${_config.accounts === false || (!others.length && !switcher) ? '' : `<div class="openvibe-navbar-dropdown-accounts" role="group" aria-label="Switch account">${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')}-- ${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Switch to Anonymous</span></div>` : ''}++ ${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Anonymous</span></div>` : ''}</div>`}<div class="openvibe-navbar-dropdown-menu">${grid.length ? `<div class="ud-grid">${grid.map(tileHTML).join('')}</div>` : ''}@@@ -1622,7 +1725,7 @@nav.appendChild(dropdown);bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));bindChips(dropdown);-- if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same) { if (_config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });++ if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; const path = sitePath(a); if (path != null) { if (_config.onNavigate(path, e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });bindDisplayRows(dropdown);regPanel(dropdown, 'user-menu');@@@ -1701,17 +1804,43 @@let _searchLoading = false;/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */function openSearch(q, tab) {-- const go = () => root.OpenVibeSearch && root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,-- frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope) });++ const orb = _navEl && _navEl.querySelector('#openvibe-search-btn');++ // The menu drops from the orb, under the bar (the bar stays, and the orb's × closes it).++ const anchor = () => {++ if (!_navEl || !orb || !orb.getBoundingClientRect) return null;++ const bar = _navEl.getBoundingClientRect(), at = orb.getBoundingClientRect();++ return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6 } : null;++ };++ const go = () => {++ if (orb) orb.classList.remove('is-busy');++ if (!root.OpenVibeSearch) return;++ if (_navEl) _navEl.classList.remove('ovnav-hide');++ root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,++ frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon,++ onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); } });++ const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'true');++ };if (root.OpenVibeSearch) return go();if (_searchLoading) return;_searchLoading = true;-- try { const sc = document.createElement('script'); sc.src = sibling('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; }; document.head.appendChild(sc); } catch { _searchLoading = false; }++ if (orb) orb.classList.add('is-busy');++ try { const sc = document.createElement('script'); sc.src = sibling('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; if (orb) orb.classList.remove('is-busy'); }; document.head.appendChild(sc); } catch { _searchLoading = false; }}++ const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open'));++ let _orbIntro = false;let _searchKeys = false;function hookSearch(nav) {const b = nav.querySelector('#openvibe-search-btn');-- if (b) { b.addEventListener('click', () => openSearch()); for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true }); }++ if (b) {++ b.addEventListener('click', () => { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else openSearch(); });++ for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });++ if (menuOpen()) b.setAttribute('aria-expanded', 'true');++ // One ripple a tab, a moment after the first paint: here is the menu.++ let seen = _orbIntro; try { seen = seen || sessionStorage.getItem('ov-orb-intro') === '1'; sessionStorage.setItem('ov-orb-intro', '1'); } catch { /* */ }++ // The timers look the orb up when they fire: holding this one would keep a swapped-out bar alive (boost).++ const orbNow = () => _navEl && _navEl.querySelector && _navEl.querySelector('#openvibe-search-btn');++ if (!seen && b.classList) { _orbIntro = true; setTimeout(() => { const o = orbNow(); if (o) o.classList.add('ovo-intro'); setTimeout(() => { const o2 = orbNow(); if (o2) o2.classList.remove('ovo-intro'); }, 1100); }, 700); }++ }if (_searchKeys || _config.search === false || typeof document === 'undefined') return;_searchKeys = true;document.addEventListener('keydown', (e) => {@@@ -1730,10 +1859,22 @@// The bar's at-the-top state (ovnav-top, see injectStyles), on every render and scroll. One window listener pair for// the page's life, a frame at most per update; --ovnav-real-h is the bar's height for overlay pages.-- let _topHooked = false;++ let _topHooked = false, _lastY = 0;function syncTop() {if (!_navEl) return;-- _navEl.classList.toggle('ovnav-top', (root.scrollY || (document.documentElement && document.documentElement.scrollTop) || 0) <= 4);++ const y = root.scrollY || (document.documentElement && document.documentElement.scrollTop) || 0;++ _navEl.classList.toggle('ovnav-top', y <= 4);++ // Phones: away while reading down, back on the first scroll up; never with one of its menus open or focus in it.++ if (_config.autoHide !== false) {++ const phone = !!(root.matchMedia && root.matchMedia('(max-width: 760px)').matches);++ const busy = !!(_navEl.querySelector('.open, [aria-expanded="true"]') || (document.body && document.body.classList.contains('ov-panel-open')) || menuOpen()++ || (document.activeElement && document.activeElement !== document.body && _navEl.contains(document.activeElement)));++ const dy = y - _lastY;++ if (!phone || y < 90 || busy) _navEl.classList.remove('ovnav-hide');++ else if (dy > 10) _navEl.classList.add('ovnav-hide');++ else if (dy < -6) _navEl.classList.remove('ovnav-hide');++ }++ _lastY = y;// A fixed bar (a site that positions it itself, like Live) takes no room, so an overlay page needs no pull-up.const fixed = root.getComputedStyle && root.getComputedStyle(_navEl).position === 'fixed';const de = document.documentElement;@@@ -1804,6 +1945,14 @@/** 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. */++ setMessages(count, spec) {++ if (spec !== undefined) { _config.messages = spec; if (_navEl) render(); }++ _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()); }++ },++/** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */addMenuItem(item) {if (!item || !item.label) return;@@@ -4,8 +4,10 @@* <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion* <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)* Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).-- * The O is a ring with a comet arc running around it, the V is drawn inside with a light-- * sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.++ * The mark is one stroke, the "open loop" (4.25.0; the owner, 2026-10-28: "the open vibe logo isn't even that great…++ * come up with new ideas… more modern… better effects"): an O opened at the top by the V, so it reads OV and "open" at++ * once. The stroke runs from the site's accent towards violet; it draws itself in on a visit's first view, a short light++ * runs along it, and the site's dot sits in the opening (Live's is red and on air). Transform/opacity/stroke motion only.** Motion budget: SVG animations restyle and re-lay-out on the main thread every frame, so an* endlessly moving mark kept idle pages 12-29% busy (browser check, OpenVibe.Host). A mark now@@@ -27,90 +29,86 @@if (window.__ovMark) return; window.__ovMark = true;let n = 0;const CSS = `--.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0}--.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%;animation:ovmFloat 5s ease-in-out infinite}--.ov-mark .g{fill:currentColor;opacity:.1;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}--.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}--.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}--.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}--.ov-mark .d{fill:var(--ovm-dot,#fff);transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}--/* Per-site variants: one family, each site with its own motion so the network reads as one brand-- with many rooms. Colours stay with the theme (currentColor); only the dot and the rhythm change. */++.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0;--ovm-to:color-mix(in oklab,currentColor 50%,#e879f9)}++.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%}++.ov-mark .g{fill:currentColor;opacity:.12;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}++.ov-mark .lp{fill:none;stroke-width:5.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:100;animation:ovmDraw 1.2s cubic-bezier(.3,.8,.2,1) both}++.ov-mark .sw{fill:none;stroke:#fff;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:7 93;opacity:.9;animation:ovmSweep 3s linear infinite}++.ov-mark .d{fill:var(--ovm-dot,var(--text-primary,#fff));transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}++/* Per-site variants: one family, each site with its own rhythm so the network reads as one brand with many rooms.++ Colours stay with the theme (currentColor); only the dot and the motion change. */.ov-mark[data-variant=live]{--ovm-dot:#ef4444}.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}--.ov-mark[data-variant=tools] .c{animation-duration:2.2s;stroke-dasharray:22 91}--.ov-mark[data-variant=tools] .o2{display:none}--.ov-mark[data-variant=media] .c{stroke-dasharray:14 14 14 71;animation-duration:4.4s}++.ov-mark[data-variant=tools] .sw{animation-duration:2s}++.ov-mark[data-variant=media] .sw,.ov-mark[data-variant=video] .sw{stroke-dasharray:3 6;animation-duration:6s}.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}.ov-mark[data-variant=games] .d{animation-duration:1.6s}--.ov-mark[data-variant=community] .o2{opacity:.9;r:1.8}--.ov-mark[data-variant=community] .c{animation-direction:reverse}++.ov-mark[data-variant=community] .sw{animation-direction:reverse}.ov-mark[data-variant=network] .g{animation-duration:2.4s}.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}--.ov-mark[data-variant=stream] .c{stroke-dasharray:60 53;animation-duration:2.8s}--.ov-mark[data-variant=codes] .v{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}--.ov-mark[data-variant=blog] .c,.ov-mark[data-variant=wiki] .c,.ov-mark[data-variant=news] .c{animation-duration:6s}++.ov-mark[data-variant=stream] .sw{stroke-dasharray:20 80;animation-duration:2.4s}++.ov-mark[data-variant=codes] .lp{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}++.ov-mark[data-variant=blog] .sw,.ov-mark[data-variant=wiki] .sw,.ov-mark[data-variant=news] .sw{animation-duration:6s}.ov-mark[data-variant=vip]{--ovm-dot:#facc15}.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}--.ov-mark[data-variant=trade] .c{animation-timing-function:cubic-bezier(.4,0,.2,1)}++.ov-mark[data-variant=trade] .sw{animation-timing-function:cubic-bezier(.4,0,.2,1)}.ov-mark[data-variant=host] svg{animation:none}--@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.7}50%{transform:scale(1.7);opacity:1}}++@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.75}50%{transform:scale(1.6);opacity:1}}@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}--.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}--.ov-mark .o2{opacity:.5}.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}--.ov-mark:hover .c{animation-duration:.9s}--.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}--.ov-mark[data-static] .o{display:none}--.ov-mark[data-ov]:not([data-ovm-run]) svg,.ov-mark[data-ov]:not([data-ovm-run]) .g,.ov-mark[data-ov]:not([data-ovm-run]) .c,.ov-mark[data-ov]:not([data-ovm-run]) .d{animation-play-state:paused!important}--.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .v{animation:none!important;stroke-dashoffset:0}--.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}++.ov-mark:hover .sw,a:hover>.ov-mark .sw{animation-duration:1s}++.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .lp,.ov-mark[data-static] .sw,.ov-mark[data-static] .d{animation:none!important}++.ov-mark[data-static] .sw{display:none}++.ov-mark[data-ov]:not([data-ovm-run]) svg,.ov-mark[data-ov]:not([data-ovm-run]) .g,.ov-mark[data-ov]:not([data-ovm-run]) .sw,.ov-mark[data-ov]:not([data-ovm-run]) .d{animation-play-state:paused!important}++.ov-mark[data-ov]:not([data-ovm-run]) .sw{opacity:0;transition:opacity .4s}++.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .lp{animation:none!important;stroke-dashoffset:0}++.ov-mark .p{fill:none;stroke:currentColor;stroke-width:3.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:138.2;stroke-dashoffset:calc(138.2px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}.ov-mark[data-progress] .p{opacity:1}--.ov-mark[data-progress] .c,.ov-mark[data-progress] .o{opacity:.25}--.ov-mark[data-state=busy] .c{animation-duration:1.1s}++.ov-mark[data-progress] .sw{opacity:0}++.ov-mark[data-state=busy] .sw{animation-duration:1s}.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}--@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}--@keyframes ovmComet{to{transform:rotate(360deg)}}--@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}--@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}--@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}--.ov-mark .sh,.ov-mark .sw{display:none}--html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw{display:inline}--.ov-mark .sr{fill:none;stroke:currentColor;stroke-width:2;opacity:.28}++@keyframes ovmDraw{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}++@keyframes ovmSweep{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}++@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.45);opacity:1}}++@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.15)}to{transform:rotate(360deg) scale(1)}}++.ov-mark .sh,.ov-mark .sw2{display:none}++html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw2{display:inline}++html[data-ov-season-mark=halloween] .ov-mark .d{display:none}.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:3.2;stroke-linecap:round}.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 70%}--.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:5.4;stroke-linecap:round}++.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:4.6;stroke-linecap:round}.ov-mark .sd{fill:#f4fbff}--html[data-ov-season-mark=halloween] .ov-mark .o{fill:#ffc27a}++html[data-ov-season-mark=halloween] .ov-mark{--ovm-to:#f97316}html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) both}--.ov-mark[data-ovm-flourish] .sr{animation:ovmFlicker 1.6s linear 1}.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}++.ov-mark[data-ovm-flourish] .sw{animation:ovmSweep .9s linear 1;opacity:.9}.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}--@keyframes ovmFlicker{0%,100%{opacity:.28}12%{opacity:.7}22%{opacity:.2}38%{opacity:.8}55%{opacity:.35}72%{opacity:.65}}@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}80%{opacity:.9}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}--@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d,.ov-mark .sl,.ov-mark .sr,.ov-mark .sd{animation:none!important}.ov-mark .o,.ovm-fall{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;++@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .lp,.ov-mark .sw,.ov-mark .d,.ov-mark .sl,.ov-mark .sd{animation:none!important}.ov-mark .sw,.ovm-fall{display:none}}++html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;++ // The open loop: from the opening's left end round the bottom to its right end, then down the V and back up.++ // pathLength=100 lets the draw-in and the running light be written in hundredths of the stroke.++ const LOOP = 'M15.2 8.8A17.5 17.5 0 1 0 32.8 8.8L24 30.5Z';function svg(id) {return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>--<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>--<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>--<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>--<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>--<circle class="p" cx="24" cy="24" r="18"/><g class="sh"><path class="sr" d="M24 6.5C16.5 12 16.5 36 24 41.5M24 6.5C31.5 12 31.5 36 24 41.5"/></g>--<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>--<g class="sh"><path class="ss" d="M23.4 7.4c.2-2.6 1-4.4 2.6-5.6"/><path class="sl" d="M25.8 3.6c2.2-2.4 5.6-2.6 8-1.2-1.8 2.6-5.2 3.4-8 1.2z"/></g>--<g class="sw"><path class="sc" d="M12.4 10.2A18 18 0 0 1 35.6 10.2"/><circle class="sd" cx="16.6" cy="9.8" r="1.6"/><circle class="sd" cx="30.6" cy="9.4" r="1.3"/></g>--<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>--<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;++<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" style="stop-color:var(--ovm-to)"/></linearGradient></defs>++<circle class="g" cx="24" cy="24" r="22"/>++<circle class="p" cx="24" cy="24" r="22"/>++<path class="lp" d="${LOOP}" pathLength="100" stroke="url(#${id}g)"/>++<path class="sw" d="${LOOP}" pathLength="100"/>++<circle class="d" cx="24" cy="7.4" r="2.7"/>++<g class="sh"><path class="ss" d="M24 9.6c.2-2.8 1-4.8 2.7-6.1"/><path class="sl" d="M26.4 4.6c2.3-2.5 5.8-2.7 8.3-1.2-1.9 2.7-5.4 3.5-8.3 1.2z"/></g>++<g class="sw2"><path class="sc" d="M6.6 15.4A19.4 19.4 0 0 1 14.3 7.2"/><path class="sc" d="M33.7 7.2A19.4 19.4 0 0 1 41.4 15.4"/><circle class="sd" cx="10.4" cy="9" r="1.4"/><circle class="sd" cx="38.2" cy="9.6" r="1.2"/></g></svg>`;}const INTRO_MS = 8000, LINGER_MS = 1200;const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.24.4",++ "version": "4.25.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -23,7 +23,8 @@ const OUT = path.join(__dirname, '..', 'icons');const SERVICES = path.join(contracts, 'manifests', 'services');function serviceIds() {-- return fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).sort();++ // A page card (live-chat, live-rundown, …) belongs to its site, which has the icons and the manifest.++ return fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).filter((id) => !social.pageOf(id)).sort();}function siteOf(id) {@@@ -207,7 +207,12 @@.ovs-grid.is-sites { grid-template-columns: repeat(2, minmax(0, 1fr)); }.ovs-tile small { -webkit-line-clamp: 1; }}--@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar { animation: none; } }`;++/* Dropped from the navbar's orb (4.25.0): under the bar, from the orb's side, the bar still showing. */++.ovs.ovs-anchored { top: var(--ovs-top, 0px); background: rgba(3, 6, 12, .45); }++.ovs.ovs-anchored .ovs-panel { top: 8px; left: clamp(8px, var(--ovs-left, 12px), calc(100vw - min(720px, calc(100vw - 16px)) - 8px)); transform: none; transform-origin: 24px 0; max-height: calc(100dvh - var(--ovs-top, 0px) - 24px); animation: ovs-drop .22s cubic-bezier(.2,1.15,.3,1); }++@keyframes ovs-drop { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: none; } }++@media (max-width: 640px) { .ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: auto; max-height: calc(100dvh - var(--ovs-top, 0px) - 12px); border-radius: 0 0 22px 22px; border-top: 0; } }++@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel { animation: none; } }`;document.head.appendChild(st);}@@@ -350,7 +355,8 @@g.items.forEach((it) => {if (as === 'chips') {const a = option('a', 'ovs-chip', it);-- if (ICON[it.icon]) a.insertAdjacentHTML('afterbegin', ICON[it.icon]);++ // The place's own icon (the navbar's drawn set, never page text), else the menu's.++ if (it.drawn && /^<svg[\s>]/.test(it.drawn)) a.insertAdjacentHTML('afterbegin', it.drawn); else if (ICON[it.icon]) a.insertAdjacentHTML('afterbegin', ICON[it.icon]);const s = document.createElement('span'); s.textContent = it.title; a.appendChild(s);box.appendChild(a);return;@@@ -375,7 +381,7 @@function pagesOf(q) {const n = q.toLowerCase();return (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden && (!n || l.label.toLowerCase().includes(n)))-- .slice(0, n ? 4 : 8).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', kind: 'page' })).filter((x) => x.url);++ .slice(0, n ? 4 : 8).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', drawn: l.icon && typeof ctx.navIcon === 'function' ? ctx.navIcon(l.icon) : '', kind: 'page' })).filter((x) => x.url);}function actions(q) {const out = [];@@@ -510,6 +516,11 @@lastFocus = document.activeElement;tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';el.classList.add('is-open');++ // Opened from the navbar's orb: the menu drops from it, under the bar; otherwise centred.++ const at = opts.anchor;++ el.classList.toggle('ovs-anchored', !!at);++ if (at) { el.style.setProperty('--ovs-top', `${Math.max(0, Math.round(at.top))}px`); el.style.setProperty('--ovs-left', `${Math.max(8, Math.round(at.left))}px`); }++ try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }document.documentElement.style.overflow = 'hidden';input.placeholder = `Search ${here()}, or jump to any site`;input.value = opts.q || '';@@@ -526,6 +537,8 @@el.classList.remove('is-open');document.documentElement.style.overflow = '';if (inflight) { inflight.abort(); inflight = null; }++ if (ctx && typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }++ try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: false } })); } catch { /* */ }if (lastFocus && lastFocus.focus) lastFocus.focus();}BBinary files a/social/actor.jpg and b/social/actor.jpg differBBinary files a/social/ai.jpg and b/social/ai.jpg differBBinary files a/social/blog.jpg and b/social/blog.jpg differBBinary files a/social/bot.jpg and b/social/bot.jpg differBBinary files a/social/chat.jpg and b/social/chat.jpg differBBinary files a/social/codes.jpg and b/social/codes.jpg differBBinary files a/social/community.jpg and b/social/community.jpg differBBinary files a/social/coupons.jpg and b/social/coupons.jpg differBBinary files a/social/deals.jpg and b/social/deals.jpg differBBinary files a/social/events.jpg and b/social/events.jpg differBBinary files a/social/food.jpg and b/social/food.jpg differBBinary files a/social/games.jpg and b/social/games.jpg differBBinary files a/social/help.jpg and b/social/help.jpg differBBinary files a/social/host.jpg and b/social/host.jpg differBBinary files a/social/inventory.jpg and b/social/inventory.jpg differBBinary files a/social/live-chat.jpg and b/social/live-chat.jpg differBBinary files a/social/live-content.jpg and b/social/live-content.jpg differBBinary files a/social/live-moments.jpg and b/social/live-moments.jpg differBBinary files a/social/live-rundown.jpg and b/social/live-rundown.jpg differBBinary files a/social/live.jpg and b/social/live.jpg differBBinary files a/social/media-hub.jpg and b/social/media-hub.jpg differBBinary files a/social/media.jpg and b/social/media.jpg differBBinary files a/social/network.jpg and b/social/network.jpg differBBinary files a/social/news.jpg and b/social/news.jpg differBBinary files a/social/openre.jpg and b/social/openre.jpg differBBinary files a/social/play.jpg and b/social/play.jpg differBBinary files a/social/quest.jpg and b/social/quest.jpg differBBinary files a/social/rent.jpg and b/social/rent.jpg differBBinary files a/social/reviews.jpg and b/social/reviews.jpg differBBinary files a/social/run.jpg and b/social/run.jpg differBBinary files a/social/search.jpg and b/social/search.jpg differBBinary files a/social/services.jpg and b/social/services.jpg differBBinary files a/social/space.jpg and b/social/space.jpg differBBinary files a/social/tips.jpg and b/social/tips.jpg differBBinary files a/social/tools.jpg and b/social/tools.jpg differBBinary files a/social/trade.jpg and b/social/trade.jpg differBBinary files a/social/vip.jpg and b/social/vip.jpg differBBinary files a/social/watch.jpg and b/social/watch.jpg differBBinary files a/social/wiki.jpg and b/social/wiki.jpg differBBinary files a/social/work.jpg and b/social/work.jpg differBBinary files a/social/zone.jpg and b/social/zone.jpg differ@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 37.5 * 1024; // 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)++const NAVBAR_BROTLI_BUDGET = 39.5 * 1024; // 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)// ── size budget and generated block ──────────────────────────{@@@ -76,6 +76,8 @@ const admin = { id: 1, username: 'alex', display_name: 'Alex', role: 'admin' };if (name === 'fa-user-secret') continue; // "Switch to Anonymous": only where openvibe.network's account switcher runsif (name === 'fa-chevron-down') continue; // the account menu's More: only past eight places (4.23.0)if (name === 'fa-bell') continue; // the Notifications place: only where the bar has no bell (4.23.0)++ if (name === 'fa-comment-dots') continue; // messages beside the bell: only for a site that has them (4.25.0)++ if (name === 'fa-wallet') continue; // the Vibes balance in the menu, drawn when it first opens (4.25.0)assert.ok(out.includes(glyph(name)), `${name} drawn inline by the default navbar`);}assert.ok(!/data-ovnav-ic|fa-solid/.test(out), 'no lazy slot or icon-font fallback in the default navbar');@@@ -3,7 +3,8 @@// name was drawn under the launcher and the magnifier (the owner's screenshot, 2026-10-28): the right-hand group// (magnifier, balances, bell, picture, menu) left the brand ~19px and the name did not clip. Now, from 320px to 430px:// nothing in the bar overlaps or runs off the screen, the site's word reads whole from 360px, the folded balances--// show the first one, and a signed-in phone keeps the launcher in the menu. Skipped without Chrome.++// show the first one, and a signed-in phone keeps the launcher in the menu. Since 4.25.0 both balances show at every++// width and the menu's orb sits beside the brand. Skipped without Chrome.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -70,7 +71,9 @@ const MEASURE = `(() => {const last = v.parts[v.parts.length - 1];assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);if (width >= 360) assert.ok(!v.clipped && /Games/.test(v.visibleText), `${at}: the site's word reads whole (${v.visibleText})`);-- if (who === 'in') assert.strictEqual(v.minis, width <= 440 ? 1 : 2, `${at}: the folded balances show ${width <= 440 ? 'the first one' : 'both'}`);++ if (who === 'in') assert.strictEqual(v.minis, 2, `${at}: the folded balances show both, OpenCoins and Vibes (4.25.0)`);++ const orb = v.parts.find(([k]) => k === 'search');++ assert.ok(orb && orb[1][0] < width / 2, `${at}: the OpenVibe menu's orb sits by the brand, on the left (4.25.0)`);assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: no launcher button: the magnifier opens the one menu (4.23.0)`);await p.close();}@@@ -110,7 +110,7 @@ const hrefs = (html) => [...html.matchAll(/href="([^"]+)"/g)].map((m) => unescapawait new Promise((r) => setImmediate(r));const menu = made.find((e) => e.className === 'openvibe-navbar-dropdown');assert.ok(/data-account-id="8"/.test(menu.innerHTML), 'in place');-- assert.ok(/Switch to Anonymous/.test(menu.innerHTML));++ assert.ok(/data-account-id="anon"[^>]*title="Browse without your name"[\s\S]*?<span>Anonymous<\/span>/.test(menu.innerHTML), 'the Anonymous pill, whole (4.25.0)');const add = new URL(hrefs(menu.innerHTML).find((h) => h.includes('add_account=')));assert.strictEqual(add.searchParams.get('return'), 'https://openvibe.network/my', 'on the Network the page itself is the sign-in');}@@@ -36,14 +36,14 @@ function load(store, answers) {const calls = [];global.fetch = async (url) => {calls.push(url);-- const a = /\/api\/me\/vibes$/.test(url) ? answers.vibes : /\/api\/coins\/me$/.test(url) ? answers.coins : { status: 404, body: {} };++ const a = /\/api\/me\/vibes$/.test(url) ? answers.vibes : /\/api\/coins\/me(\?|$)/.test(url) ? answers.coins : { status: 404, body: {} };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 };}const settle = () => new Promise((r) => setTimeout(r, 20));--const wallet = (calls) => calls.filter((u) => /\/api\/(coins\/me|me\/vibes)$/.test(u));++const wallet = (calls) => calls.filter((u) => /\/api\/(coins\/me|me\/vibes)(\?|$)/.test(u));const chip = (html, id) => (new RegExp(`<button[^>]*data-chip-id="${id}"[^>]*>`).exec(html) || [''])[0];const user = { id: 7, subject: 'usr_7', username: 'maya' };const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.network', user, token: 'tok', wallet: true };@@@ -57,7 +57,8 @@ const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.nenavbar.init(base);assert.match(chip(navs[0].innerHTML, 'coins'), /hidden/, 'nothing to show before the balances arrive');await settle();-- assert.deepStrictEqual(wallet(calls).sort(), ['https://billing.openvibe.network/api/me/vibes', 'https://openvibe.network/api/coins/me']);++ // An older Network answers the coins only: Vibes come from Billing itself (4.25.0 asks the Network for both first).++ assert.deepStrictEqual(wallet(calls).sort(), ['https://billing.openvibe.network/api/me/vibes', 'https://openvibe.network/api/coins/me?with=vibes']);const last = navs[navs.length - 1].innerHTML;assert.ok(navs.length >= 2, 'drawn again once the chips appeared');assert.doesNotMatch(chip(last, 'coins'), /hidden/);@@@ -67,6 +68,20 @@ const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.neassert.match(last, /aria-label="Your balances: OpenCoins 160,774, Vibes 2,500"/);assert.deepStrictEqual(JSON.parse(store['ov-wallet:usr_7']), { coins: '160,774', vibes: '2,500' });++ // A Network that answers both (4.25.0): one request, Billing never asked (no site's connect-src allowed it).++ {++ const store2 = {};++ const both = { coins: { status: 200, body: { balance: 160774, vibes: 2500 } }, vibes: { status: 500, body: {} } };++ const r = load(store2, both);++ r.navbar.init(base);++ await settle();++ assert.deepStrictEqual(wallet(r.calls), ['https://openvibe.network/api/coins/me?with=vibes'], 'one request for both balances');++ assert.match(r.navs[r.navs.length - 1].innerHTML, /data-chip-id="vibes"[^>]*>[\s\S]*?2,500/);++ }++ ({ navbar, navs, calls } = load(store, answers));++ navbar.init(base);++ await settle();++// Drawn again on the same page within a minute: no second request.const before = wallet(calls).length;navbar.init(base);@@@ -28,13 +28,15 @@ function dropdown() {configure({ token: 'tok', apiBase: 'https://openvibe.network', menu: null });let d = dropdown();await loadWallet(d);-- assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me', 'https://billing.openvibe.network/api/me/vibes']);++ // The Network first, for both (4.25.0); an older one answers the coins only, so Billing itself after it.++ assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes', 'https://billing.openvibe.network/api/me/vibes']);assert.strictEqual(calls[1].init.headers.Authorization, 'Bearer tok');assert.strictEqual(calls[1].init.credentials, 'omit', 'no cookies to Billing');assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);assert.match(d.els['#openvibe-wallet'].innerHTML, /1,234/);assert.strictEqual(d.els['#openvibe-vibes'].hidden, false);-- assert.match(d.els['#openvibe-vibes'].innerHTML, /5,600 Vibes/);++ assert.match(d.els['#openvibe-vibes'].innerHTML, /5,600 <small>Vibes<\/small>/);++ assert.match(d.els['#openvibe-wallet'].innerHTML, /1,234 <small>OpenCoins<\/small>/, 'each balance says what it is');assert.match(d.els['#openvibe-vibes'].innerHTML, /href="https:\/\/openvibe\.live\/#vibes"/);await loadWallet(d);@@@ -61,6 +63,16 @@ function dropdown() {await loadWallet(dropdown());assert.strictEqual(calls.length, 0);++ // A Network that answers both: one request, Billing never asked.++ configure({ token: 'tok', menu: null, wallet: false, chips: [] });++ answer.coins = { status: 200, body: { balance: 1234, vibes: 77 } };++ calls.length = 0;++ d = dropdown();++ await loadWallet(d);++ assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes']);++ assert.match(d.els['#openvibe-vibes'].innerHTML, /77 <small>Vibes<\/small>/);++ answer.coins = { status: 200, body: { balance: 1234 } };++// billingBase points elsewhere (a test cell).configure({ token: 'tok', billingBase: 'https://billing.example.test' });calls.length = 0;@@@ -70,7 +70,7 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, htm{const t = setup();const css = t.document.getElementById('ov-mark-css').textContent;-- assert.match(css, /\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) svg,[^{]*\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) \.c,[^{]*\{animation-play-state:paused!important\}/, 'paused rule');++ assert.match(css, /\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) svg,[^{]*\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) \.sw,[^{]*\{animation-play-state:paused!important\}/, 'paused rule (the running light, 4.25.0)');assert.ok(t.mark.querySelector('svg'), 'mounted');assert.ok(t.io.targets.has(t.mark), 'observed for visibility');// Not yet known to be on screen: still, SMIL parked on the centred V sweep.
@@ -6,6 +6,43 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,43 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.25.0 — 2026-10-28
+
+The brand bar, from the owner's phone on 2026-10-28.
+
+- **A new mark: the open loop** (`ov-mark.js`, `app-icon.js`): one stroke, an O opened at the top by the V, with the
+ site's dot in the opening. It replaces the ring, comet, white V and orbiting dot.
+ - **Colour:** from the accent towards violet.
+ - **Motion:** it draws itself in, and a short light runs along it.
+ - **Light themes:** the dot follows the text colour, so it shows there.
+ - **Icons and cards:** app icons (`icons/`), favicons and share cards (`social/`) are redrawn with it.
+ - The owner: "the open vibe logo isn't even that great… new ideas… more modern… better effects".
+- **The OpenVibe menu's orb, beside the brand** (`navbar.js`). The magnifier on the right is gone (the owner: "the
+ little diamond thing… cool animations… next to the logo").
+ - **The orb:** nine dots as a diamond. Hovered, a ripple runs from the centre; it waves while the menu loads, and it is
+ an × while the menu is open (a second click closes it). Nothing moves at rest.
+ - **The menu** (`search.js`) drops from the orb under the bar: a panel on a computer, a sheet as tall as its contents
+ on a phone. "On this site" chips carry the place's own icon.
+ - **Events:** `search.js` sends `openvibe-menu` events and calls `onClose`.
+- **Both balances on phones.** The folded pill shows OpenCoins and Vibes at every width (it showed the first one up
+ to 440 px). The bar's padding, gaps, picture and buttons tighten under 400 px; under 350 px the site's word gives
+ way to the mark.
+- **Vibes beside OpenCoins in the account menu.**
+ - **One request:** the navbar asks OpenVibe.Network once, `GET /api/coins/me?with=vibes`, Network#373. Every site's
+ connect-src allows the Network and none allowed Billing, so Vibes never showed. An older Network falls back to
+ Billing.
+ - **Labels:** each balance names itself.
+- **The account menu on a phone** is a sheet the width of the screen.
+ - **Tiles:** they never leave one alone on a row (five share a row, six make two of three).
+ - **Anonymous:** the pill reads whole.
+ - **Footer:** its buttons share the row evenly.
+- **On a phone the bar steps away** while you read down and comes back on the first scroll up. It never does with a
+ menu open or focus inside it. `autoHide: false` keeps it.
+- **Messages beside the bell** (`messages: { onOpen, href }`, `setMessages(n)` for the unread count), for a site
+ that would otherwise float a bubble over the page (the owner: "this DMs button is like buggy as hell on mobile").
+- **Routers get paths.** `onNavigate` (the bar, the account menu and the app bar) receives `/@name?tab=x`, never a
+ full address. On Live, "My channel" (`https://openvibe.live/@name`) had opened `/https://openvibe.live/@name`.
+
## 4.24.4 — 2026-10-28
## 4.24.4 — 2026-10-28
- **`agent-session.js`: no made-up run time.** A finished run mounted without `startedAt` (a replay) showed "0 s"; it
- **`agent-session.js`: no made-up run time.** A finished run mounted without `startedAt` (a replay) showed "0 s"; it
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.24.4 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.25.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.24.4",
+ "version": "4.25.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// openvibe-shared/app-icon — one app icon design for every OpenVibe site.
// openvibe-shared/app-icon — one app icon design for every OpenVibe site.
//
//
-// The icon is the OV mark as it appears in the navbar (ring, comet, white V, dot) on the network's
+// The icon is the OV mark as it appears in the navbar (the open loop and the site's dot) on the network's
// deep navy, tinted per site. No text: a home-screen icon is a few millimetres wide.
// deep navy, tinted per site. No text: a home-screen icon is a few millimetres wide.
// svg({ site, maskable }) full-bleed square SVG. maskable keeps the mark inside the 40 % radius
// svg({ site, maskable }) full-bleed square SVG. maskable keeps the mark inside the 40 % radius
// safe zone, so round, squircle and teardrop masks never clip it.
// safe zone, so round, squircle and teardrop masks never clip it.
@@ -26,19 +26,18 @@ const SITES = {
@@ -26,19 +26,18 @@ const SITES = {
const of = (site) => SITES[site] || SITES.network;
const of = (site) => SITES[site] || SITES.network;
function mark(site, scale) {
function mark(site, scale) {
- // Drawn in a 48-unit box centred on 0,0 (same geometry as ov-mark.js), then scaled.
+ // Drawn in a 48-unit box centred on 0,0 (ov-mark.js's open loop moved by -24,-24), then scaled.
const c = of(site);
const c = of(site);
return `<g transform="scale(${scale})">
return `<g transform="scale(${scale})">
-<circle r="21" fill="url(#glow)"/>
-<circle r="18" fill="none" stroke="url(#ring)" stroke-width="4" opacity=".38"/>
-<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#ring)" stroke-width="4.4" stroke-linecap="round"/>
-<path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="#fff" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>
-<circle cx="17.4" cy="4.6" r="3.2" fill="${c.dot}"/></g>`;
+<circle r="22" fill="url(#glow)"/>
+<path d="M-8.8,-15.2 A17.5,17.5 0 1,0 8.8,-15.2 L0,6.5 Z" fill="none" stroke="url(#ring)" stroke-width="5.6" stroke-linecap="round" stroke-linejoin="round"/>
+<circle cy="-16.6" r="3" fill="${c.dot}"/></g>`;
}
}
function defs(site) {
function defs(site) {
const [a, b] = of(site).accent;
const [a, b] = of(site).accent;
- return `<defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${a}"/><stop offset="1" stop-color="${b}"/></linearGradient>
+ // The stroke runs from the site's accent towards violet, as the navbar's mark does.
+ return `<defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${a}"/><stop offset=".65" stop-color="${b}"/><stop offset="1" stop-color="#c026d3"/></linearGradient>
<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".34"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient>
<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".34"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient>
<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;
<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;
}
}
@@ -90,7 +90,7 @@
@@ -90,7 +90,7 @@
if (e.defaultPrevented) return;
if (e.defaultPrevented) return;
if (typeof ctx.config.onNavigate === 'function') {
if (typeof ctx.config.onNavigate === 'function') {
let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }
let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }
- if (same && ctx.config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault();
+ if (same && ctx.config.onNavigate((() => { try { const u = new URL(a.href, location.href); return `${u.pathname}${u.search}${u.hash}`; } catch { return a.getAttribute('href'); } })(), e) === false) e.preventDefault();
}
}
});
});
document.body.appendChild(bar);
document.body.appendChild(bar);
@@ -11,7 +11,7 @@ theme, and they fit a phone first.
@@ -11,7 +11,7 @@ theme, and they fit a phone first.
loads only this one.
loads only this one.
- **`kit.css`**: the look, built on the site's theme tokens.
- **`kit.css`**: the look, built on the site's theme tokens.
- **`appbar.js`**: the phone tab bar. `navbar.js` loads it only for a site that sets `appBar`.
- **`appbar.js`**: the phone tab bar. `navbar.js` loads it only for a site that sets `appBar`.
-- **`search.js`**: search across OpenVibe from the navbar's magnifier, loaded on first use.
+- **`search.js`**: the OpenVibe menu (search, every site, every tool) behind the orb beside the brand, loaded on first use.
- **`loading.js`**: the one loading look (an overlay over the thing's own picture) and skeleton rows.
- **`loading.js`**: the one loading look (an overlay over the thing's own picture) and skeleton rows.
The package is MIT. Every site serves its own pinned copy at `/shared/kit.css`, `/shared/kit.js`, `/shared/kit-web.js` and
The package is MIT. Every site serves its own pinned copy at `/shared/kit.css`, `/shared/kit.js`, `/shared/kit-web.js` and
@@ -45,7 +45,7 @@ The same day, a sweep measured the front page of all 38 live sites at 390 px wid
@@ -45,7 +45,7 @@ The same day, a sweep measured the front page of all 38 live sites at 390 px wid
```
```
┌──────────────────────────────┐
┌──────────────────────────────┐
-│ navbar (sticky) │ OpenVibe Frame: brand, launcher, bell, account
+│ navbar (sticky) │ OpenVibe Frame: brand + menu orb, balances, bell, account
├──────────────────────────────┤
├──────────────────────────────┤
│ ‹ Title ⓘ ••• │ kit.head: back, title, one muted line, icon actions ┐ sticky under
│ ‹ Title ⓘ ••• │ kit.head: back, title, one muted line, icon actions ┐ sticky under
│ ( Look ) Privacy Sound … │ kit.tabs: the page's parts, one at a time ┘ the navbar
│ ( Look ) Privacy Sound … │ kit.tabs: the page's parts, one at a time ┘ the navbar
@@ -210,10 +210,11 @@ The rules since 4.22.0:
@@ -210,10 +210,11 @@ The rules since 4.22.0:
icons make the phone app bar (`appBar: true`).
icons make the phone app bar (`appBar: true`).
2. **The rest is `more: true`.** Those links fold into one "More" on computers and are listed in the phone drawer:
2. **The rest is `more: true`.** Those links fold into one "More" on computers and are listed in the phone drawer:
guides, sources, "how it works", the API.
guides, sources, "how it works", the API.
-3. **Search is the magnifier**, not a link: `search: { owner, label, page }` points it at the site's own results and
- search page.
+3. **Search is the orb beside the brand**, not a link: `search: { owner, label, page }` points it at the site's own
+ results and search page.
4. **"What shipped" lives in the footer** (Updates), which every site has.
4. **"What shipped" lives in the footer** (Updates), which every site has.
-5. **The account menu holds the person's things** (dashboard, settings, admin), and the launcher holds the other sites.
+5. **The account menu holds the person's things** (dashboard, settings, admin), and the OpenVibe menu holds the other
+ sites.
The bar no longer repeats them (`networkLinks` brings the strip back for a site that really wants it).
The bar no longer repeats them (`networkLinks` brings the strip back for a site that really wants it).
```js
```js
@@ -229,14 +230,40 @@ OpenVibeNavbar.init({ service: 'food', links: [
@@ -229,14 +230,40 @@ OpenVibeNavbar.init({ service: 'food', links: [
**On a phone** the bar keeps the site's name readable and never stacks one control on another (4.22.1):
**On a phone** the bar keeps the site's name readable and never stacks one control on another (4.22.1):
- **The name:** the site's word alone ("Games", not "OpenVibe.Games") up to 480 px. When there is still no room, it
- **The name:** the site's word alone ("Games", not "OpenVibe.Games") up to 480 px. When there is still no room, it
ends with an ellipsis and is never drawn under the buttons.
ends with an ellipsis and is never drawn under the buttons.
-- **Signed in:**
- - the balances fold into one pill showing the first balance, up to 440 px (a tap lists them all);
- - the launcher steps into the menu, whose "All sites" opens it.
-- **Everyone under 375 px:** the launcher steps into the menu.
-- **Spacing:** the right-hand buttons sit closer together under 400 px.
+- **Signed in:** the balances fold into one pill under 560 px that always shows **both**, OpenCoins and Vibes (4.25.0;
+ a tap lists them in full). Under 440 px the pill tightens; under 350 px the site's word gives way to the mark so the
+ balances keep their icons.
+- **The menu's orb** stays beside the brand at every width.
+- **Spacing:** under 400 px the bar's padding, gaps, picture and buttons tighten.
+- **Reading down a phone** (under 760 px), the bar steps away and comes back on the first scroll up. It never does
+ with one of its menus open or focus inside it. `autoHide: false` keeps it.
+- **The account menu** is a sheet the width of the screen under 560 px. Its tiles never leave one alone on a row.
+
+**Messages** (4.25.0): a site with messages puts them beside the bell instead of a bubble over the page.
+
+```js
+OpenVibeNavbar.init({ …, messages: { onOpen: () => openInbox(), href: '/messages' } }) // href without onOpen: a link
+OpenVibeNavbar.setMessages(3) // the unread count (0 hides it)
+```
+
+**Routers get paths** (4.25.0): `onNavigate(path, event)` receives `/@name?tab=x#y` for a link on this site, never a
+full address. The account menu's "My channel" is `https://openvibe.live/@name` on every site, and Live's router once
+put it under `/`.
`test/navbar-phone-fit-chrome.test.js` measures the bar from 320 to 430 px, signed in and out.
`test/navbar-phone-fit-chrome.test.js` measures the bar from 320 to 430 px, signed in and out.
+## The mark
+
+`ov-mark.js` draws the OpenVibe mark (4.25.0, the "open loop"): one stroke, an O opened at the top by the V, so it
+reads OV and "open" at once. The site's dot sits in the opening.
+- **Colour:** the stroke runs from the site's accent towards violet (`--ovm-to`).
+- **Motion:**
+ - it draws itself in on a visit's first view, and a short light runs along it while it moves;
+ - otherwise it rests (the motion budget).
+- **Variants:** each site keeps its own rhythm (`data-variant`). Live's dot is red and on air.
+- **App icons, favicons and share cards** draw the same loop (`app-icon.js`, `scripts/build-icons.js`,
+ `scripts/build-social-cards.js`).
+
## The app bar
## The app bar
```js
```js
@@ -258,8 +285,15 @@ the site's main verb (go live, post, ask). Settings and the rest live in More.
@@ -258,8 +285,15 @@ the site's main verb (go live, post, ask). Settings and the rest live in More.
## The OpenVibe menu: search, every site, every tool
## The OpenVibe menu: search, every site, every tool
-One control on every site: the navbar's magnifier (or `/`, or Ctrl+K) opens the OpenVibe menu. It used to be two
-panels doing the same job, the launcher's dots (every site) and the magnifier (search). The owner, 2026-10-28:
+One control on every site: the orb beside the brand (or `/`, or Ctrl+K) opens the OpenVibe menu. It used to be two
+panels doing the same job, the launcher's dots (every site) and the magnifier (search).
+
+**The orb** (4.25.0; the owner: "the little diamond thing… an icon that has cool animations and effects… next to the
+logo"): nine dots set as a diamond, the centre in the accent.
+- **Hovered:** a ripple runs out from the centre and the dots take the accent's colours.
+- **While the menu loads:** the dots wave.
+- **While it is open:** the diamond turns into an × that closes it.
+- **At rest it doesn't move**, and reduced motion turns all of it off. The owner, 2026-10-28:
"combine… a better hierarchy… use this user's history… autocomplete… maybe use tabs". The navbar loads `search.js` the
"combine… a better hierarchy… use this user's history… autocomplete… maybe use tabs". The navbar loads `search.js` the
first time someone asks, so a page nobody searches pays nothing (and the navbar itself got 15 KB lighter).
first time someone asks, so a page nobody searches pays nothing (and the navbar itself got 15 KB lighter).
@@ -284,7 +318,9 @@ completion shows faintly behind the text; Tab or → takes it.
@@ -284,7 +318,9 @@ completion shows faintly behind the text; Tab or → takes it.
**Keys:** arrows move, Enter opens, Escape closes.
**Keys:** arrows move, Enter opens, Escape closes.
-**Layout:** a phone gets the whole screen; a computer gets a panel near the top, with the keys listed at its foot.
+**Layout:** opened from the orb, the menu drops from it under the bar. On a computer it is a panel from the orb's side, with
+the keys listed at its foot. On a phone it is a sheet as tall as its contents. Opened any other way, it is a centred
+panel (a phone gets the whole screen).
```js
```js
OpenVibeNavbar.init({ …, search: { owner: 'live', label: 'OpenVibe.Live', page: '/search' } }) // or search: false
OpenVibeNavbar.init({ …, search: { owner: 'live', label: 'OpenVibe.Live', page: '/search' } }) // or search: false
Binary files a/icons/ai-192.png and b/icons/ai-192.png differ
Binary files a/icons/ai-192.png and b/icons/ai-192.png differ
Binary files a/icons/ai-512.png and b/icons/ai-512.png differ
Binary files a/icons/ai-512.png and b/icons/ai-512.png differ
Binary files a/icons/ai-maskable-192.png and b/icons/ai-maskable-192.png differ
Binary files a/icons/ai-maskable-192.png and b/icons/ai-maskable-192.png differ
Binary files a/icons/ai-maskable-512.png and b/icons/ai-maskable-512.png differ
Binary files a/icons/ai-maskable-512.png and b/icons/ai-maskable-512.png differ
Binary files a/icons/community-192.png and b/icons/community-192.png differ
Binary files a/icons/community-192.png and b/icons/community-192.png differ
Binary files a/icons/community-512.png and b/icons/community-512.png differ
Binary files a/icons/community-512.png and b/icons/community-512.png differ
Binary files a/icons/community-maskable-192.png and b/icons/community-maskable-192.png differ
Binary files a/icons/community-maskable-192.png and b/icons/community-maskable-192.png differ
Binary files a/icons/community-maskable-512.png and b/icons/community-maskable-512.png differ
Binary files a/icons/community-maskable-512.png and b/icons/community-maskable-512.png differ
Binary files a/icons/games-192.png and b/icons/games-192.png differ
Binary files a/icons/games-192.png and b/icons/games-192.png differ
Binary files a/icons/games-512.png and b/icons/games-512.png differ
Binary files a/icons/games-512.png and b/icons/games-512.png differ
Binary files a/icons/games-maskable-192.png and b/icons/games-maskable-192.png differ
Binary files a/icons/games-maskable-192.png and b/icons/games-maskable-192.png differ
Binary files a/icons/games-maskable-512.png and b/icons/games-maskable-512.png differ
Binary files a/icons/games-maskable-512.png and b/icons/games-maskable-512.png differ
Binary files a/icons/live-192.png and b/icons/live-192.png differ
Binary files a/icons/live-192.png and b/icons/live-192.png differ
Binary files a/icons/live-512.png and b/icons/live-512.png differ
Binary files a/icons/live-512.png and b/icons/live-512.png differ
Binary files a/icons/live-maskable-192.png and b/icons/live-maskable-192.png differ
Binary files a/icons/live-maskable-192.png and b/icons/live-maskable-192.png differ
Binary files a/icons/live-maskable-512.png and b/icons/live-maskable-512.png differ
Binary files a/icons/live-maskable-512.png and b/icons/live-maskable-512.png differ
Binary files a/icons/media-192.png and b/icons/media-192.png differ
Binary files a/icons/media-192.png and b/icons/media-192.png differ
Binary files a/icons/media-512.png and b/icons/media-512.png differ
Binary files a/icons/media-512.png and b/icons/media-512.png differ
Binary files a/icons/media-maskable-192.png and b/icons/media-maskable-192.png differ
Binary files a/icons/media-maskable-192.png and b/icons/media-maskable-192.png differ
Binary files a/icons/media-maskable-512.png and b/icons/media-maskable-512.png differ
Binary files a/icons/media-maskable-512.png and b/icons/media-maskable-512.png differ
Binary files a/icons/network-192.png and b/icons/network-192.png differ
Binary files a/icons/network-192.png and b/icons/network-192.png differ
Binary files a/icons/network-512.png and b/icons/network-512.png differ
Binary files a/icons/network-512.png and b/icons/network-512.png differ
Binary files a/icons/network-maskable-192.png and b/icons/network-maskable-192.png differ
Binary files a/icons/network-maskable-192.png and b/icons/network-maskable-192.png differ
Binary files a/icons/network-maskable-512.png and b/icons/network-maskable-512.png differ
Binary files a/icons/network-maskable-512.png and b/icons/network-maskable-512.png differ
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"actor": {
"actor": {
"name": "OpenVibe.Actor",
"name": "OpenVibe.Actor",
- "description": "Your agent for everything",
+ "description": "Agents that work while you are away",
"icon": "network"
"icon": "network"
},
},
"ai": {
"ai": {
@@ -16,17 +16,17 @@
@@ -16,17 +16,17 @@
},
},
"bot": {
"bot": {
"name": "OpenVibe.Bot",
"name": "OpenVibe.Bot",
- "description": "An open control panel for robots",
+ "description": "Drive any robot from your browser",
"icon": "network"
"icon": "network"
},
},
"chat": {
"chat": {
"name": "OpenVibe.Chat",
"name": "OpenVibe.Chat",
- "description": "One chat for the network",
+ "description": "Chat with Vibe, friends and groups",
"icon": "network"
"icon": "network"
},
},
"codes": {
"codes": {
"name": "OpenVibe.Codes",
"name": "OpenVibe.Codes",
- "description": "Code with any agent",
+ "description": "Build and fix apps with AI",
"icon": "network"
"icon": "network"
},
},
"community": {
"community": {
@@ -56,7 +56,7 @@
@@ -56,7 +56,7 @@
},
},
"games": {
"games": {
"name": "OpenVibe.Games",
"name": "OpenVibe.Games",
- "description": "Browser games",
+ "description": "Browser games, and games you make",
"icon": "games"
"icon": "games"
},
},
"help": {
"help": {
@@ -76,7 +76,7 @@
@@ -76,7 +76,7 @@
},
},
"live": {
"live": {
"name": "OpenVibe.Live",
"name": "OpenVibe.Live",
- "description": "Streams, clips and chat",
+ "description": "Go live from your browser",
"icon": "live"
"icon": "live"
},
},
"media": {
"media": {
@@ -86,7 +86,7 @@
@@ -86,7 +86,7 @@
},
},
"media-hub": {
"media-hub": {
"name": "OpenVibe.Video",
"name": "OpenVibe.Video",
- "description": "Past streams, clips and your drive",
+ "description": "Videos, pictures and your files",
"icon": "media"
"icon": "media"
},
},
"network": {
"network": {
@@ -106,7 +106,7 @@
@@ -106,7 +106,7 @@
},
},
"play": {
"play": {
"name": "OpenVibe.Play",
"name": "OpenVibe.Play",
- "description": "Achievements and leaderboards for every OpenVibe game.",
+ "description": "Achievements, leaderboards and cloud saves for every game.",
"icon": "network"
"icon": "network"
},
},
"quest": {
"quest": {
@@ -116,7 +116,7 @@
@@ -116,7 +116,7 @@
},
},
"rent": {
"rent": {
"name": "OpenVibe.Rent",
"name": "OpenVibe.Rent",
- "description": "Find a place, or rent yours out",
+ "description": "Every rental site, one search",
"icon": "network"
"icon": "network"
},
},
"reviews": {
"reviews": {
@@ -126,7 +126,7 @@
@@ -126,7 +126,7 @@
},
},
"run": {
"run": {
"name": "OpenVibe.Run",
"name": "OpenVibe.Run",
- "description": "Run a job on a node that can",
+ "description": "Run jobs on machines that can.",
"icon": "network"
"icon": "network"
},
},
"search": {
"search": {
@@ -141,12 +141,12 @@
@@ -141,12 +141,12 @@
},
},
"space": {
"space": {
"name": "OpenVibe.Space",
"name": "OpenVibe.Space",
- "description": "Static sites, hosted by OpenVibe",
+ "description": "Make a space: an app on your account",
"icon": "space"
"icon": "space"
},
},
"tips": {
"tips": {
"name": "OpenVibe.Tips",
"name": "OpenVibe.Tips",
- "description": "Support creators",
+ "description": "Tips, paid messages and alerts for any stream",
"icon": "network"
"icon": "network"
},
},
"tools": {
"tools": {
@@ -161,7 +161,7 @@
@@ -161,7 +161,7 @@
},
},
"vip": {
"vip": {
"name": "OpenVibe.VIP",
"name": "OpenVibe.VIP",
- "description": "Memberships and perks",
+ "description": "Memberships for creators and their software",
"icon": "network"
"icon": "network"
},
},
"watch": {
"watch": {
Binary files a/icons/space-192.png and b/icons/space-192.png differ
Binary files a/icons/space-192.png and b/icons/space-192.png differ
Binary files a/icons/space-512.png and b/icons/space-512.png differ
Binary files a/icons/space-512.png and b/icons/space-512.png differ
Binary files a/icons/space-maskable-192.png and b/icons/space-maskable-192.png differ
Binary files a/icons/space-maskable-192.png and b/icons/space-maskable-192.png differ
Binary files a/icons/space-maskable-512.png and b/icons/space-maskable-512.png differ
Binary files a/icons/space-maskable-512.png and b/icons/space-maskable-512.png differ
Binary files a/icons/tools-192.png and b/icons/tools-192.png differ
Binary files a/icons/tools-192.png and b/icons/tools-192.png differ
Binary files a/icons/tools-512.png and b/icons/tools-512.png differ
Binary files a/icons/tools-512.png and b/icons/tools-512.png differ
Binary files a/icons/tools-maskable-192.png and b/icons/tools-maskable-192.png differ
Binary files a/icons/tools-maskable-192.png and b/icons/tools-maskable-192.png differ
Binary files a/icons/tools-maskable-512.png and b/icons/tools-maskable-512.png differ
Binary files a/icons/tools-maskable-512.png and b/icons/tools-maskable-512.png differ
@@ -30,7 +30,12 @@
@@ -30,7 +30,12 @@
/* At the very top the bar fades into the page (no background, no border); a scroll brings the frosted bar back.
/* At the very top the bar fades into the page (no background, no border); a scroll brings the frosted bar back.
A page whose hero runs under the bar opts in with body.ov-nav-overlay: the bar then sits over the hero, with a
A page whose hero runs under the bar opts in with body.ov-nav-overlay: the bar then sits over the hero, with a
soft scrim for the text. ovnav-top is kept by the navbar itself on every render and scroll. */
soft scrim for the text. ovnav-top is kept by the navbar itself on every render and scroll. */
- .openvibe-navbar { transition: background-color .45s cubic-bezier(.2,.7,.2,1), border-color .45s cubic-bezier(.2,.7,.2,1), box-shadow .45s cubic-bezier(.2,.7,.2,1), -webkit-backdrop-filter .45s, backdrop-filter .45s; }
+ .openvibe-navbar { transition: background-color .45s cubic-bezier(.2,.7,.2,1), border-color .45s cubic-bezier(.2,.7,.2,1), box-shadow .45s cubic-bezier(.2,.7,.2,1), -webkit-backdrop-filter .45s, backdrop-filter .45s, transform .32s cubic-bezier(.2,.7,.2,1); }
+ /* On a phone the bar steps away while you read down the page and comes back the moment you scroll up (4.25.0;
+ the owner, 2026-10-28: the bar's at-the-top effect "doesn't work on mobile… the whole thing needs to be
+ improved"). Never while one of its menus is open. A site turns it off with autoHide: false. */
+ .openvibe-navbar.ovnav-hide { transform: translateY(calc(-100% - 10px)); box-shadow: none; }
+ #navbar-mount:has(> .ovnav-hide) { pointer-events: none; }
.openvibe-navbar::before { content: ""; position: absolute; inset: 0 0 -28px; pointer-events: none; z-index: -1; opacity: 0; transition: opacity .45s cubic-bezier(.2,.7,.2,1); background: linear-gradient(180deg, rgba(4,7,14,.62), rgba(4,7,14,.28) 55%, rgba(4,7,14,0)); }
.openvibe-navbar::before { content: ""; position: absolute; inset: 0 0 -28px; pointer-events: none; z-index: -1; opacity: 0; transition: opacity .45s cubic-bezier(.2,.7,.2,1); background: linear-gradient(180deg, rgba(4,7,14,.62), rgba(4,7,14,.28) 55%, rgba(4,7,14,0)); }
.openvibe-navbar:not(.ovnav-top) { background-color: color-mix(in srgb, var(--bg-secondary, #252530) 84%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px); box-shadow: 0 12px 32px -14px rgba(0,0,0,.6); }
.openvibe-navbar:not(.ovnav-top) { background-color: color-mix(in srgb, var(--bg-secondary, #252530) 84%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px); box-shadow: 0 12px 32px -14px rgba(0,0,0,.6); }
.openvibe-navbar.ovnav-top { background-color: transparent; border-bottom-color: transparent; box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; }
.openvibe-navbar.ovnav-top { background-color: transparent; border-bottom-color: transparent; box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; }
@@ -151,8 +156,14 @@
@@ -151,8 +156,14 @@
.openvibe-navbar .openvibe-navbar-brand { flex: 0 1 auto; }
.openvibe-navbar .openvibe-navbar-brand { flex: 0 1 auto; }
.openvibe-navbar .openvibe-navbar-brand a.flame { flex: none; }
.openvibe-navbar .openvibe-navbar-brand a.flame { flex: none; }
.openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }
.openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }
- @media (max-width: 440px) { .openvibe-navbar .ovnav-chip--all .ovnav-chip-mini ~ .ovnav-chip-mini { display: none; } }
- @media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 2px; } }
+ /* Both balances on every phone (the owner, 2026-10-28: "you can only see one… use the full space"): the pill
+ tightens instead of folding to one. */
+ @media (max-width: 440px) { .ovnav-chip-mini { padding: 0 5px; gap: 3px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 12px; } .ovnav-chip-mini .ovnav-ic { width: 13px; height: 13px; } }
+ @media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 1px; } .ovnav-chip-mini { padding: 0 3px; gap: 2px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 11.5px; letter-spacing: -.2px; } .openvibe-navbar .openvibe-navbar-brand { gap: 4px; }
+ .openvibe-navbar { padding-left: 8px; padding-right: 6px; gap: 2px; } .openvibe-navbar .ovnav-burger { width: 34px; } .openvibe-navbar .openvibe-navbar-avatar { width: 32px; height: 32px; box-sizing: border-box; }
+ .openvibe-navbar .openvibe-navbar-brand .name { font-size: 14px; letter-spacing: -.3px; } .openvibe-navbar .openvibe-navbar-brand a.flame { width: 28px; height: 28px; } }
+ /* Under 350px the mark alone names the site, so both balances keep their icons (a number alone says nothing). */
+ @media (max-width: 350px) { .openvibe-navbar .openvibe-navbar-brand .name { display: none; } }
.openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
.openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
@@ -204,8 +215,44 @@
@@ -204,8 +215,44 @@
.openvibe-navbar-spacer { flex: 1; }
.openvibe-navbar-spacer { flex: 1; }
.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
.openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
- .openvibe-navbar .ovnav-search { all: unset; box-sizing: border-box; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; cursor: pointer; flex: none; }
- .openvibe-navbar .ovnav-search:hover, .openvibe-navbar .ovnav-search:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ /* 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. */
+ .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; }
+ .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; }
+ .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; } }
+ @media (prefers-reduced-motion: reduce) { .ovnav-orb .ovo-g, .ovnav-orb .ovo-d, .ovnav-orb::before { transition: none; animation: none !important; } }
+ html[data-ov-motion="reduced"] .ovnav-orb .ovo-d { animation: none !important; }
+ .openvibe-navbar .openvibe-navbar-brand .ovnav-orb { margin-left: 1px; }
+ @media (max-width: 400px) { .openvibe-navbar .ovnav-orb { width: 28px; height: 32px; } }
+ /* 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: 19px; height: 19px; }
+ .openvibe-navbar .ovnav-msgs b { position: absolute; top: -1px; right: -3px; min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--live-red, #ef4444); color: #fff; font: 700 10px/16px Inter, system-ui, sans-serif; text-align: center; box-shadow: 0 0 0 2px var(--bg-secondary, #111826); }
+ .openvibe-navbar .ovnav-msgs b:empty { display: none; }
+ @media (max-width: 400px) { .openvibe-navbar .ovnav-msgs { width: 32px; height: 32px; } }
.openvibe-navbar-avatar {
.openvibe-navbar-avatar {
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
@@ -270,7 +317,7 @@
@@ -270,7 +317,7 @@
color: var(--text-primary, #e0e0e0); text-decoration: none; transition: background .12s;
color: var(--text-primary, #e0e0e0); text-decoration: none; transition: background .12s;
}
}
.openvibe-navbar-dropdown-accounts .account-item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.openvibe-navbar-dropdown-accounts .account-item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .openvibe-navbar-dropdown-accounts .account-item.ud-anon { padding-left: 9px; color: var(--text-secondary, #a8b3c4); }
+ .openvibe-navbar-dropdown-accounts .account-item.ud-anon { padding-left: 9px; max-width: none; color: var(--text-secondary, #a8b3c4); }
.openvibe-navbar-dropdown-accounts .account-item:hover, .openvibe-navbar-dropdown-accounts .account-item:focus-visible { background: var(--bg-hover, #2f2f3d); }
.openvibe-navbar-dropdown-accounts .account-item:hover, .openvibe-navbar-dropdown-accounts .account-item:focus-visible { background: var(--bg-hover, #2f2f3d); }
.openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }
.openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }
.openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; }
.openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; }
@@ -300,9 +347,26 @@
@@ -300,9 +347,26 @@
.openvibe-navbar-dropdown-menu .ud-more summary:hover { background: var(--bg-hover, #2f2f3d); }
.openvibe-navbar-dropdown-menu .ud-more summary:hover { background: var(--bg-hover, #2f2f3d); }
.openvibe-navbar-dropdown-menu .ud-more[open] summary .icon { transform: rotate(180deg); }
.openvibe-navbar-dropdown-menu .ud-more[open] summary .icon { transform: rotate(180deg); }
.openvibe-navbar-dropdown-menu .ud-more .ud-n { margin-left: auto; font-size: 11.5px; color: var(--text-muted, #7d8aa0); }
.openvibe-navbar-dropdown-menu .ud-more .ud-n { margin-left: auto; font-size: 11.5px; color: var(--text-muted, #7d8aa0); }
+ /* The tiles never leave one alone on a row: five or fewer share one row, six make two of three, seven and
+ eight two of four (the owner's phone, 2026-10-28: an Admin tile on a row of its own). */
+ .openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(3):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(5):last-child) { grid-template-columns: repeat(5, minmax(0, 1fr)); }
+ .openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(6):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ /* The balances in the header: OpenCoins and Vibes side by side, each a coloured icon, a number and its name. */
+ .openvibe-navbar-dropdown-header .ud-wallet a small { font-weight: 600; opacity: .75; font-size: 10.5px; }
+ /* On a phone the menu is a sheet the width of the screen under the bar, not a narrow card off to the right. */
+ @media (max-width: 560px) {
+ .openvibe-navbar .openvibe-navbar-dropdown { left: 8px; right: 8px; width: auto; max-width: none; border-radius: 18px; }
+ .openvibe-navbar-dropdown-header { padding: 14px 10px 12px 14px; }
+ .openvibe-navbar-dropdown-header img { width: 48px; height: 48px; }
+ .openvibe-navbar-dropdown-header .info .name { font-size: 16px; }
+ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile { min-height: 72px; font-size: 12px; }
+ .openvibe-navbar-dropdown-accounts .account-item { max-width: 46vw; }
+ }
/* One row at the foot: Light, Dark, Auto, text size, motion, themes, and Sign out. */
/* One row at the foot: Light, Dark, Auto, text size, motion, themes, and Sign out. */
.openvibe-navbar-dropdown-menu .ud-foot { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 4px -8px -6px; padding: 8px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent); }
.openvibe-navbar-dropdown-menu .ud-foot { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 4px -8px -6px; padding: 8px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent); }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib { width: auto; min-width: 34px; height: 34px; padding: 0 8px; justify-content: center; gap: 3px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib { width: auto; min-width: 34px; height: 34px; padding: 0 8px; justify-content: center; gap: 3px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); }
+ .openvibe-navbar-dropdown-menu .ud-foot > .ud-ib { flex: 1 1 0; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .icon { width: 18px; color: inherit; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .icon { width: 18px; color: inherit; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val { margin: 0; font-size: 11px; font-weight: 700; color: inherit; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val { margin: 0; font-size: 11px; font-weight: 700; color: inherit; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val:empty { display: none; }
.openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val:empty { display: none; }
@@ -373,12 +437,14 @@
@@ -373,12 +437,14 @@
'fa-text-height': [576, 'M64 139V107V139V107H128V427H96Q82 427 73 436Q64 445 64 459Q64 473 73 482Q82 491 96 491H224Q238 491 247 482Q256 473 256 459Q256 445 247 436Q238 427 224 427H192V107H256V139Q256 153 265 162Q274 171 288 171Q302 171 311 162Q320 153 320 139V91Q319 71 306 57Q292 44 272 43H160H48Q28 44 14 57Q1 71 0 91V139Q0 153 9 162Q18 171 32 171Q46 171 55 162Q64 153 64 139ZM503 52Q493 43 480 43Q467 43 457 52L393 116Q379 132 386 151Q395 170 416 171H448V363H416Q395 364 386 383Q379 402 393 418L457 482Q467 491 480 491Q493 491 503 482L567 418Q581 402 574 383Q565 364 544 363H512V171H544Q565 170 574 151Q581 132 567 116L503 52Z'],
'fa-text-height': [576, 'M64 139V107V139V107H128V427H96Q82 427 73 436Q64 445 64 459Q64 473 73 482Q82 491 96 491H224Q238 491 247 482Q256 473 256 459Q256 445 247 436Q238 427 224 427H192V107H256V139Q256 153 265 162Q274 171 288 171Q302 171 311 162Q320 153 320 139V91Q319 71 306 57Q292 44 272 43H160H48Q28 44 14 57Q1 71 0 91V139Q0 153 9 162Q18 171 32 171Q46 171 55 162Q64 153 64 139ZM503 52Q493 43 480 43Q467 43 457 52L393 116Q379 132 386 151Q395 170 416 171H448V363H416Q395 364 386 383Q379 402 393 418L457 482Q467 491 480 491Q493 491 503 482L567 418Q581 402 574 383Q565 364 544 363H512V171H544Q565 170 574 151Q581 132 567 116L503 52Z'],
'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],
'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],
'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],
'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],
+ 'fa-wallet': [512, 'M64 43Q37 44 19 62Q1 80 0 107V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V203Q511 176 493 158Q475 140 448 139H80Q65 138 64 123Q65 108 80 107H448Q462 107 471 98Q480 89 480 75Q480 61 471 52Q462 43 448 43H64ZM416 283Q430 283 439 292Q448 301 448 315Q448 329 439 338Q430 347 416 347Q402 347 393 338Q384 329 384 315Q384 301 393 292Q402 283 416 283Z'],
'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],
'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],
'fa-chevron-down': [512, 'M233 418Q243 427 256 427Q269 427 279 418L471 226Q480 216 480 203Q480 190 471 180Q461 171 448 171Q435 171 425 180L256 350L87 180Q77 171 64 171Q51 171 41 180Q32 190 32 203Q32 216 41 226L233 418Z'],
'fa-chevron-down': [512, 'M233 418Q243 427 256 427Q269 427 279 418L471 226Q480 216 480 203Q480 190 471 180Q461 171 448 171Q435 171 425 180L256 350L87 180Q77 171 64 171Q51 171 41 180Q32 190 32 203Q32 216 41 226L233 418Z'],
'fa-gear': [512, 'M496 178Q500 192 490 202L446 242Q448 254 448 267Q448 280 446 292L490 332Q500 342 496 356Q489 374 480 391L475 399Q465 415 453 430Q443 441 429 437L373 419Q353 435 329 444L317 502Q313 516 298 519Q278 523 256 523Q234 523 213 519Q199 516 195 502L183 444Q159 435 139 419L83 437Q69 441 59 430Q46 415 37 399L32 391Q23 374 16 357Q12 342 22 332L66 293Q64 280 64 267Q64 254 66 242L22 202Q12 192 16 178Q23 160 32 143L37 135Q46 119 59 104Q69 93 83 97L139 115Q159 99 183 89L195 32Q199 18 214 15Q234 11 256 11Q278 11 299 15Q313 18 317 32L329 89Q353 99 373 115L429 97Q443 93 453 104Q466 119 476 135L480 143Q489 160 496 178ZM256 347Q301 346 325 307Q347 267 325 227Q301 188 256 187Q211 188 187 227Q165 267 187 307Q211 346 256 347Z'],
'fa-gear': [512, 'M496 178Q500 192 490 202L446 242Q448 254 448 267Q448 280 446 292L490 332Q500 342 496 356Q489 374 480 391L475 399Q465 415 453 430Q443 441 429 437L373 419Q353 435 329 444L317 502Q313 516 298 519Q278 523 256 523Q234 523 213 519Q199 516 195 502L183 444Q159 435 139 419L83 437Q69 441 59 430Q46 415 37 399L32 391Q23 374 16 357Q12 342 22 332L66 293Q64 280 64 267Q64 254 66 242L22 202Q12 192 16 178Q23 160 32 143L37 135Q46 119 59 104Q69 93 83 97L139 115Q159 99 183 89L195 32Q199 18 214 15Q234 11 256 11Q278 11 299 15Q313 18 317 32L329 89Q353 99 373 115L429 97Q443 93 453 104Q466 119 476 135L480 143Q489 160 496 178ZM256 347Q301 346 325 307Q347 267 325 227Q301 188 256 187Q211 188 187 227Q165 267 187 307Q211 346 256 347Z'],
'fa-circle-half-stroke': [512, 'M448 267Q446 185 392 131Q338 77 256 75V459Q338 457 392 403Q446 349 448 267ZM0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267Z'],
'fa-circle-half-stroke': [512, 'M448 267Q446 185 392 131Q338 77 256 75V459Q338 457 392 403Q446 349 448 267ZM0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267Z'],
'fa-moon': [384, 'M224 43Q161 44 111 74Q60 103 30 154Q1 204 0 267Q1 330 31 380Q60 431 111 460Q161 490 224 491Q317 489 379 428Q387 419 382 409Q377 399 365 400Q351 403 335 403Q261 401 211 351Q162 302 160 227Q160 177 184 137Q209 97 249 74Q259 68 257 56Q254 46 242 44Q233 43 223 43Z'],
'fa-moon': [384, 'M224 43Q161 44 111 74Q60 103 30 154Q1 204 0 267Q1 330 31 380Q60 431 111 460Q161 490 224 491Q317 489 379 428Q387 419 382 409Q377 399 365 400Q351 403 335 403Q261 401 211 351Q162 302 160 227Q160 177 184 137Q209 97 249 74Q259 68 257 56Q254 46 242 44Q233 43 223 43Z'],
'fa-sun': [512, 'M362 12Q369 16 371 24L391 132L499 152Q507 154 511 161Q514 169 509 177L447 267L509 357Q514 365 511 373Q507 380 499 382L391 402L371 510Q369 518 362 522Q354 525 346 520L256 458L166 520Q158 525 151 522Q143 518 141 510L121 402L13 382Q5 380 1 373Q-2 365 3 357L65 267L3 177Q-2 169 1 162Q5 154 13 152L121 132L141 24Q143 16 151 12Q158 9 166 14L256 76L346 14Q354 9 362 12ZM160 267Q160 241 173 219Q186 197 208 184Q231 171 256 171Q281 171 304 184Q326 197 339 219Q352 241 352 267Q352 293 339 315Q326 337 304 350Q281 363 256 363Q231 363 208 350Q186 337 173 315Q160 293 160 267ZM384 267Q384 232 367 203Q350 174 320 156Q290 139 256 139Q222 139 192 156Q162 174 145 203Q128 232 128 267Q128 302 145 331Q162 360 192 378Q222 395 256 395Q290 395 320 378Q350 360 367 331Q384 302 384 267Z'],
'fa-sun': [512, 'M362 12Q369 16 371 24L391 132L499 152Q507 154 511 161Q514 169 509 177L447 267L509 357Q514 365 511 373Q507 380 499 382L391 402L371 510Q369 518 362 522Q354 525 346 520L256 458L166 520Q158 525 151 522Q143 518 141 510L121 402L13 382Q5 380 1 373Q-2 365 3 357L65 267L3 177Q-2 169 1 162Q5 154 13 152L121 132L141 24Q143 16 151 12Q158 9 166 14L256 76L346 14Q354 9 362 12ZM160 267Q160 241 173 219Q186 197 208 184Q231 171 256 171Q281 171 304 184Q326 197 339 219Q352 241 352 267Q352 293 339 315Q326 337 304 350Q281 363 256 363Q231 363 208 350Q186 337 173 315Q160 293 160 267ZM384 267Q384 232 367 203Q350 174 320 156Q290 139 256 139Q222 139 192 156Q162 174 145 203Q128 232 128 267Q128 302 145 331Q162 360 192 378Q222 395 256 395Q290 395 320 378Q350 360 367 331Q384 302 384 267Z'],
+ 'fa-comment-dots': [512, 'M256 459Q328 458 385 431Q443 403 477 356Q511 309 512 251Q511 193 477 146Q443 99 385 71Q328 44 256 43Q184 44 127 71Q69 99 35 146Q1 193 0 251Q1 320 48 372Q43 409 26 435Q18 449 11 456Q8 460 6 462Q6 463 5 463Q5 463 5 464Q-2 471 1 481Q6 491 16 491Q60 489 98 472Q133 456 152 441Q200 459 256 459ZM128 219Q142 219 151 228Q160 237 160 251Q160 265 151 274Q142 283 128 283Q114 283 105 274Q96 265 96 251Q96 237 105 228Q114 219 128 219ZM256 219Q270 219 279 228Q288 237 288 251Q288 265 279 274Q270 283 256 283Q242 283 233 274Q224 265 224 251Q224 237 233 228Q242 219 256 219ZM352 251Q352 237 361 228Q370 219 384 219Q398 219 407 228Q416 237 416 251Q416 265 407 274Q398 283 384 283Q370 283 361 274Q352 265 352 251Z'],
'fa-brain': [512, 'M184 11Q208 12 224 27Q239 43 240 67V467Q239 491 224 507Q208 522 184 523Q162 523 146 509Q131 495 128 473Q120 475 112 475Q85 474 67 456Q49 438 48 411Q48 400 52 390Q29 381 14 361Q0 341 0 315Q0 291 13 272Q25 253 46 243Q32 226 32 203Q32 180 46 163Q60 146 82 140Q80 132 80 123Q80 100 94 83Q107 67 128 61Q131 39 146 25Q162 11 184 11ZM328 11Q350 11 366 25Q381 39 384 61Q405 67 418 83Q432 100 432 123Q432 132 430 140Q452 146 466 163Q480 180 480 203Q480 226 466 243Q487 253 499 272Q512 291 512 315Q512 341 498 361Q483 381 460 390Q464 400 464 411Q463 438 445 456Q427 474 400 475Q392 475 384 473Q381 495 366 509Q350 523 328 523Q304 522 288 507Q273 491 272 467V67Q273 43 288 27Q304 12 328 11Z'],
'fa-brain': [512, 'M184 11Q208 12 224 27Q239 43 240 67V467Q239 491 224 507Q208 522 184 523Q162 523 146 509Q131 495 128 473Q120 475 112 475Q85 474 67 456Q49 438 48 411Q48 400 52 390Q29 381 14 361Q0 341 0 315Q0 291 13 272Q25 253 46 243Q32 226 32 203Q32 180 46 163Q60 146 82 140Q80 132 80 123Q80 100 94 83Q107 67 128 61Q131 39 146 25Q162 11 184 11ZM328 11Q350 11 366 25Q381 39 384 61Q405 67 418 83Q432 100 432 123Q432 132 430 140Q452 146 466 163Q480 180 480 203Q480 226 466 243Q487 253 499 272Q512 291 512 315Q512 341 498 361Q483 381 460 390Q464 400 464 411Q463 438 445 456Q427 474 400 475Q392 475 384 473Q381 495 366 509Q350 523 328 523Q304 522 288 507Q273 491 272 467V67Q273 43 288 27Q304 12 328 11Z'],
};
};
// Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives).
// Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives).
@@ -386,7 +452,7 @@
@@ -386,7 +452,7 @@
320: 'fa-chevron-right',
320: 'fa-chevron-right',
384: 'fa-xmark fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',
384: 'fa-xmark fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',
448: 'fa-book fa-hand-fist fa-user fa-bars fa-arrow-right fa-check fa-tag fa-bolt fa-font fa-lock fa-utensils',
448: 'fa-book fa-hand-fist fa-user fa-bars fa-arrow-right fa-check fa-tag fa-bolt fa-font fa-lock fa-utensils',
- 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-magnifying-glass fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',
+ 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-arrow-up-right-from-square fa-magnifying-glass fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',
576: 'fa-house fa-id-card fa-video fa-star fa-tower-cell fa-diagram-project fa-money-bill-wave fa-crown fa-ticket fa-map',
576: 'fa-house fa-id-card fa-video fa-star fa-tower-cell fa-diagram-project fa-money-bill-wave fa-crown fa-ticket fa-map',
640: 'fa-comments fa-gamepad fa-link fa-network-wired fa-people-arrows fa-shirt fa-code fa-robot fa-photo-film fa-wave-square fa-user-shield',
640: 'fa-comments fa-gamepad fa-link fa-network-wired fa-people-arrows fa-shirt fa-code fa-robot fa-photo-film fa-wave-square fa-user-shield',
};
};
@@ -599,9 +665,18 @@
@@ -599,9 +665,18 @@
return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}">
return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}">
<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
+ ${_config.search === false ? '' : orbHTML()}
</div>`;
</div>`;
}
}
+ // 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 orbHTML() {
+ 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 `<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"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><g class="ovo-g">${dots}</g></svg></button>`;
+ }
+
// ── Frame data (https://openvibe.network/api/frame) ─────────────────────────────
// ── Frame data (https://openvibe.network/api/frame) ─────────────────────────────
// Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
// Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
@@ -671,6 +746,23 @@
@@ -671,6 +746,23 @@
}
}
const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0];
const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0];
/** The chips folded into one (shown instead of them on a phone): each chip's icon and short value. */
/** The chips folded into one (shown instead of them on a phone): each chip's icon and short value. */
+ 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 (typeof m.onOpen !== 'function' || e.metaKey || e.ctrlKey || e.shiftKey) return;
+ e.preventDefault(); try { m.onOpen(e); } catch { /* the link still works */ }
+ });
+ }
+
function chipsAllHTML(chips) {
function chipsAllHTML(chips) {
const list = (Array.isArray(chips) ? chips : []).filter((c) => c && !c.hidden);
const list = (Array.isArray(chips) ? chips : []).filter((c) => c && !c.hidden);
if (list.length < 2) return '';
if (list.length < 2) return '';
@@ -723,6 +815,13 @@
@@ -723,6 +815,13 @@
});
});
}
}
+ /** A link on this site as the path a router takes (/@name?tab=x#y), or null for another site. The account menu's
+ * links are full addresses (https://openvibe.live/@name on every site), and a router handed one put it under '/'
+ * (Live, 2026-10-28: "My channel" opened /https://openvibe.live/@name). */
+ function sitePath(a) {
+ try { const u = new URL(a.href, location.href); return u.origin === location.origin ? `${u.pathname}${u.search}${u.hash}` : null; } catch { return null; }
+ }
+
/** Link behaviour: per-link onClick, in-app navigation (onNavigate), dropdowns, the mobile drawer. */
/** Link behaviour: per-link onClick, in-app navigation (onNavigate), dropdowns, the mobile drawer. */
function bindLinks(nav, links) {
function bindLinks(nav, links) {
const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l]));
const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l]));
@@ -738,8 +837,8 @@
@@ -738,8 +837,8 @@
if (dd && matchMedia('(hover: none)').matches && !dd.classList.contains('open')) { e.preventDefault(); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); dd.classList.add('open'); placeDd(dd); return; }
if (dd && matchMedia('(hover: none)').matches && !dd.classList.contains('open')) { e.preventDefault(); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); dd.classList.add('open'); placeDd(dd); return; }
if (item && typeof item.onClick === 'function') { const r = item.onClick(e); if (r === false) e.preventDefault(); closeDrawer(); if (e.defaultPrevented) return; }
if (item && typeof item.onClick === 'function') { const r = item.onClick(e); if (r === false) e.preventDefault(); closeDrawer(); if (e.defaultPrevented) return; }
if (typeof _config.onNavigate === 'function' && a.target !== '_blank') {
if (typeof _config.onNavigate === 'function' && a.target !== '_blank') {
- let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }
- if (same && _config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault();
+ const path = sitePath(a);
+ if (path != null && _config.onNavigate(path, e) === false) e.preventDefault();
}
}
closeDrawer();
closeDrawer();
});
});
@@ -812,16 +911,18 @@
@@ -812,16 +911,18 @@
if (_walletLoaded || !_config.token || inBar || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true;
if (_walletLoaded || !_config.token || inBar || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true;
const auth = { Authorization: `Bearer ${_config.token}` };
const auth = { Authorization: `Bearer ${_config.token}` };
const chip = (id, html) => { const el = dropdown.querySelector(id); if (el) { el.innerHTML = html; el.hidden = false; } };
const chip = (id, html) => { const el = dropdown.querySelector(id); if (el) { el.innerHTML = html; el.hidden = false; } };
- const coins = fetch(`${_config.apiBase}/api/coins/me`, { headers: auth, credentials: 'include' }).then(async (r) => {
- if (!r.ok) return; const j = await r.json(); const bal = Number(j.balance ?? (j.wallet && j.wallet.balance));
- if (isFinite(bal)) chip('#openvibe-wallet', `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()}</a>`);
- });
- // Vibes cross-site (the spendable balance Billing keeps). Billing answers 409 until it is the ledger: no chip then.
- const vibes = fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' }).then(async (r) => {
- if (!r.ok) return; const v = Number((await r.json()).credit);
- if (isFinite(v)) chip('#openvibe-vibes', `<a href="https://openvibe.live/#vibes" title="Your Vibes">${v.toLocaleString()} Vibes</a>`);
- });
- await Promise.all([coins.catch(() => {}), vibes.catch(() => {})]); // both chips are optional
+ const showVibes = (v) => { if (isFinite(v)) chip('#openvibe-vibes', `<a href="https://openvibe.live/#vibes" title="Your Vibes">${navIcon('fa-wallet')} ${v.toLocaleString()} <small>Vibes</small></a>`); };
+ // Both balances from the Network in one request (it asks Billing): every site's connect-src allows
+ // openvibe.network and none allowed billing.openvibe.network, so Vibes never showed (2026-10-28).
+ const both = await fetch(`${_config.apiBase}/api/coins/me?with=vibes`, { headers: auth, credentials: 'include' }).then((r) => (r.ok ? r.json() : null)).catch(() => null);
+ if (both) {
+ const bal = Number(both.balance ?? (both.wallet && both.wallet.balance));
+ if (isFinite(bal)) chip('#openvibe-wallet', `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()} <small>OpenCoins</small></a>`);
+ }
+ if (both && 'vibes' in both) { if (both.vibes != null) showVibes(Number(both.vibes)); return; }
+ // An older Network: Billing itself (409 until it is the ledger: no chip then).
+ await fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })
+ .then(async (r) => { if (r.ok) showVibes(Number((await r.json()).credit)); }).catch(() => {});
}
}
/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
@@ -1486,11 +1587,12 @@
@@ -1486,11 +1587,12 @@
OpenVibeNavbar.setChip(id, saved[id], { hidden: false });
OpenVibeNavbar.setChip(id, saved[id], { hidden: false });
};
};
const num = (v) => (isFinite(v) ? v : null);
const num = (v) => (isFinite(v) ? v : null);
- const coins = fetch(`${_config.apiBase}/api/coins/me`, { headers: auth, credentials: 'include' })
- .then(async (r) => { if (r.ok) { const j = await r.json(); put('coins', num(Number(j.balance ?? (j.wallet && j.wallet.balance)))); } });
- // Billing answers 409 until it is the ledger: no Vibes chip then.
- const vibes = fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })
- .then(async (r) => { if (r.ok) put('vibes', num(Number((await r.json()).credit))); else if (r.status === 409) put('vibes', null); });
+ // Both from the Network (it asks Billing); an older Network without vibes: Billing itself (409 = not the ledger).
+ let vibesKnown = false;
+ const coins = fetch(`${_config.apiBase}/api/coins/me?with=vibes`, { headers: auth, credentials: 'include' })
+ .then(async (r) => { if (!r.ok) return; const j = await r.json(); put('coins', num(Number(j.balance ?? (j.wallet && j.wallet.balance)))); if ('vibes' in j && j.vibes != null) { vibesKnown = true; put('vibes', num(Number(j.vibes))); } });
+ const vibes = coins.catch(() => {}).then(() => (vibesKnown ? null : fetch(`${_config.billingBase || 'https://billing.openvibe.network'}/api/me/vibes`, { headers: auth, credentials: 'omit' })
+ .then(async (r) => { if (r.ok) put('vibes', num(Number((await r.json()).credit))); else if (r.status === 409) put('vibes', null); })));
Promise.all([coins.catch(() => {}), vibes.catch(() => {})]).then(() => {
Promise.all([coins.catch(() => {}), vibes.catch(() => {})]).then(() => {
try { localStorage.setItem(walletKey(u), JSON.stringify(saved)); } catch { /* */ }
try { localStorage.setItem(walletKey(u), JSON.stringify(saved)); } catch { /* */ }
// A chip that appeared or went away changes the folded phone chip: draw the bar once more.
// A chip that appeared or went away changes the folded phone chip: draw the bar once more.
@@ -1535,8 +1637,8 @@
@@ -1535,8 +1637,8 @@
</div>
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-right">
<div class="openvibe-navbar-right">
- ${_config.search === false ? '' : '<button type="button" class="ovnav-search" id="openvibe-search-btn" aria-label="Search and every OpenVibe site (/)" title="Search and every OpenVibe site (/)"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg></button>'}
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
+ ${u && !isAnon && _config.messages ? messagesHTML() : ''}
<div id="openvibe-bell-mount"></div>
<div id="openvibe-bell-mount"></div>
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
@@ -1546,6 +1648,7 @@
@@ -1546,6 +1648,7 @@
`;
`;
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
bindLinks(nav, links);
+ bindMessages(nav);
hookSearch(nav);
hookSearch(nav);
bindChips(nav);
bindChips(nav);
bindChipsAll(nav);
bindChipsAll(nav);
@@ -1604,7 +1707,7 @@
@@ -1604,7 +1707,7 @@
</div>
</div>
${_config.accounts === false || (!others.length && !switcher) ? '' : `<div class="openvibe-navbar-dropdown-accounts" role="group" aria-label="Switch account">
${_config.accounts === false || (!others.length && !switcher) ? '' : `<div class="openvibe-navbar-dropdown-accounts" role="group" aria-label="Switch account">
${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')}
${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')}
- ${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Switch to Anonymous</span></div>` : ''}
+ ${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Anonymous</span></div>` : ''}
</div>`}
</div>`}
<div class="openvibe-navbar-dropdown-menu">
<div class="openvibe-navbar-dropdown-menu">
${grid.length ? `<div class="ud-grid">${grid.map(tileHTML).join('')}</div>` : ''}
${grid.length ? `<div class="ud-grid">${grid.map(tileHTML).join('')}</div>` : ''}
@@ -1622,7 +1725,7 @@
@@ -1622,7 +1725,7 @@
nav.appendChild(dropdown);
nav.appendChild(dropdown);
bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));
bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));
bindChips(dropdown);
bindChips(dropdown);
- if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same) { if (_config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
+ if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; const path = sitePath(a); if (path != null) { if (_config.onNavigate(path, e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
bindDisplayRows(dropdown);
bindDisplayRows(dropdown);
regPanel(dropdown, 'user-menu');
regPanel(dropdown, 'user-menu');
@@ -1701,17 +1804,43 @@
@@ -1701,17 +1804,43 @@
let _searchLoading = false;
let _searchLoading = false;
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
function openSearch(q, tab) {
function openSearch(q, tab) {
- const go = () => root.OpenVibeSearch && root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,
- frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope) });
+ const orb = _navEl && _navEl.querySelector('#openvibe-search-btn');
+ // The menu drops from the orb, under the bar (the bar stays, and the orb's × closes it).
+ const anchor = () => {
+ if (!_navEl || !orb || !orb.getBoundingClientRect) return null;
+ const bar = _navEl.getBoundingClientRect(), at = orb.getBoundingClientRect();
+ return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6 } : null;
+ };
+ const go = () => {
+ if (orb) orb.classList.remove('is-busy');
+ if (!root.OpenVibeSearch) return;
+ if (_navEl) _navEl.classList.remove('ovnav-hide');
+ root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,
+ frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon,
+ onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); } });
+ const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'true');
+ };
if (root.OpenVibeSearch) return go();
if (root.OpenVibeSearch) return go();
if (_searchLoading) return;
if (_searchLoading) return;
_searchLoading = true;
_searchLoading = true;
- try { const sc = document.createElement('script'); sc.src = sibling('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; }; document.head.appendChild(sc); } catch { _searchLoading = false; }
+ if (orb) orb.classList.add('is-busy');
+ try { const sc = document.createElement('script'); sc.src = sibling('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; if (orb) orb.classList.remove('is-busy'); }; document.head.appendChild(sc); } catch { _searchLoading = false; }
}
}
+ const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open'));
+ let _orbIntro = false;
let _searchKeys = false;
let _searchKeys = false;
function hookSearch(nav) {
function hookSearch(nav) {
const b = nav.querySelector('#openvibe-search-btn');
const b = nav.querySelector('#openvibe-search-btn');
- if (b) { b.addEventListener('click', () => openSearch()); for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true }); }
+ if (b) {
+ b.addEventListener('click', () => { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else openSearch(); });
+ for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });
+ if (menuOpen()) b.setAttribute('aria-expanded', 'true');
+ // One ripple a tab, a moment after the first paint: here is the menu.
+ let seen = _orbIntro; try { seen = seen || sessionStorage.getItem('ov-orb-intro') === '1'; sessionStorage.setItem('ov-orb-intro', '1'); } catch { /* */ }
+ // The timers look the orb up when they fire: holding this one would keep a swapped-out bar alive (boost).
+ const orbNow = () => _navEl && _navEl.querySelector && _navEl.querySelector('#openvibe-search-btn');
+ if (!seen && b.classList) { _orbIntro = true; setTimeout(() => { const o = orbNow(); if (o) o.classList.add('ovo-intro'); setTimeout(() => { const o2 = orbNow(); if (o2) o2.classList.remove('ovo-intro'); }, 1100); }, 700); }
+ }
if (_searchKeys || _config.search === false || typeof document === 'undefined') return;
if (_searchKeys || _config.search === false || typeof document === 'undefined') return;
_searchKeys = true;
_searchKeys = true;
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
@@ -1730,10 +1859,22 @@
@@ -1730,10 +1859,22 @@
// The bar's at-the-top state (ovnav-top, see injectStyles), on every render and scroll. One window listener pair for
// The bar's at-the-top state (ovnav-top, see injectStyles), on every render and scroll. One window listener pair for
// the page's life, a frame at most per update; --ovnav-real-h is the bar's height for overlay pages.
// the page's life, a frame at most per update; --ovnav-real-h is the bar's height for overlay pages.
- let _topHooked = false;
+ let _topHooked = false, _lastY = 0;
function syncTop() {
function syncTop() {
if (!_navEl) return;
if (!_navEl) return;
- _navEl.classList.toggle('ovnav-top', (root.scrollY || (document.documentElement && document.documentElement.scrollTop) || 0) <= 4);
+ const y = root.scrollY || (document.documentElement && document.documentElement.scrollTop) || 0;
+ _navEl.classList.toggle('ovnav-top', y <= 4);
+ // Phones: away while reading down, back on the first scroll up; never with one of its menus open or focus in it.
+ if (_config.autoHide !== false) {
+ const phone = !!(root.matchMedia && root.matchMedia('(max-width: 760px)').matches);
+ const busy = !!(_navEl.querySelector('.open, [aria-expanded="true"]') || (document.body && document.body.classList.contains('ov-panel-open')) || menuOpen()
+ || (document.activeElement && document.activeElement !== document.body && _navEl.contains(document.activeElement)));
+ const dy = y - _lastY;
+ if (!phone || y < 90 || busy) _navEl.classList.remove('ovnav-hide');
+ else if (dy > 10) _navEl.classList.add('ovnav-hide');
+ else if (dy < -6) _navEl.classList.remove('ovnav-hide');
+ }
+ _lastY = y;
// A fixed bar (a site that positions it itself, like Live) takes no room, so an overlay page needs no pull-up.
// A fixed bar (a site that positions it itself, like Live) takes no room, so an overlay page needs no pull-up.
const fixed = root.getComputedStyle && root.getComputedStyle(_navEl).position === 'fixed';
const fixed = root.getComputedStyle && root.getComputedStyle(_navEl).position === 'fixed';
const de = document.documentElement;
const de = document.documentElement;
@@ -1804,6 +1945,14 @@
@@ -1804,6 +1945,14 @@
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
+ /** Messages beside the bell: messages: { href?, onOpen?(), label? } in init (or here), and the unread count. */
+ setMessages(count, spec) {
+ if (spec !== undefined) { _config.messages = spec; if (_navEl) render(); }
+ _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()); }
+ },
+
/** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
/** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
addMenuItem(item) {
addMenuItem(item) {
if (!item || !item.label) return;
if (!item || !item.label) return;
@@ -4,8 +4,10 @@
@@ -4,8 +4,10 @@
* <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
* <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
* <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)
* <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)
* Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
* Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
- * The O is a ring with a comet arc running around it, the V is drawn inside with a light
- * sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.
+ * The mark is one stroke, the "open loop" (4.25.0; the owner, 2026-10-28: "the open vibe logo isn't even that great…
+ * come up with new ideas… more modern… better effects"): an O opened at the top by the V, so it reads OV and "open" at
+ * once. The stroke runs from the site's accent towards violet; it draws itself in on a visit's first view, a short light
+ * runs along it, and the site's dot sits in the opening (Live's is red and on air). Transform/opacity/stroke motion only.
*
*
* Motion budget: SVG animations restyle and re-lay-out on the main thread every frame, so an
* Motion budget: SVG animations restyle and re-lay-out on the main thread every frame, so an
* endlessly moving mark kept idle pages 12-29% busy (browser check, OpenVibe.Host). A mark now
* endlessly moving mark kept idle pages 12-29% busy (browser check, OpenVibe.Host). A mark now
@@ -27,90 +29,86 @@
@@ -27,90 +29,86 @@
if (window.__ovMark) return; window.__ovMark = true;
if (window.__ovMark) return; window.__ovMark = true;
let n = 0;
let n = 0;
const CSS = `
const CSS = `
-.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0}
-.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%;animation:ovmFloat 5s ease-in-out infinite}
-.ov-mark .g{fill:currentColor;opacity:.1;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}
-.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}
-.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}
-.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}
-.ov-mark .d{fill:var(--ovm-dot,#fff);transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
-/* Per-site variants: one family, each site with its own motion so the network reads as one brand
- with many rooms. Colours stay with the theme (currentColor); only the dot and the rhythm change. */
+.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0;--ovm-to:color-mix(in oklab,currentColor 50%,#e879f9)}
+.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%}
+.ov-mark .g{fill:currentColor;opacity:.12;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}
+.ov-mark .lp{fill:none;stroke-width:5.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:100;animation:ovmDraw 1.2s cubic-bezier(.3,.8,.2,1) both}
+.ov-mark .sw{fill:none;stroke:#fff;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:7 93;opacity:.9;animation:ovmSweep 3s linear infinite}
+.ov-mark .d{fill:var(--ovm-dot,var(--text-primary,#fff));transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
+/* Per-site variants: one family, each site with its own rhythm so the network reads as one brand with many rooms.
+ Colours stay with the theme (currentColor); only the dot and the motion change. */
.ov-mark[data-variant=live]{--ovm-dot:#ef4444}
.ov-mark[data-variant=live]{--ovm-dot:#ef4444}
.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
-.ov-mark[data-variant=tools] .c{animation-duration:2.2s;stroke-dasharray:22 91}
-.ov-mark[data-variant=tools] .o2{display:none}
-.ov-mark[data-variant=media] .c{stroke-dasharray:14 14 14 71;animation-duration:4.4s}
+.ov-mark[data-variant=tools] .sw{animation-duration:2s}
+.ov-mark[data-variant=media] .sw,.ov-mark[data-variant=video] .sw{stroke-dasharray:3 6;animation-duration:6s}
.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}
.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}
.ov-mark[data-variant=games] .d{animation-duration:1.6s}
.ov-mark[data-variant=games] .d{animation-duration:1.6s}
-.ov-mark[data-variant=community] .o2{opacity:.9;r:1.8}
-.ov-mark[data-variant=community] .c{animation-direction:reverse}
+.ov-mark[data-variant=community] .sw{animation-direction:reverse}
.ov-mark[data-variant=network] .g{animation-duration:2.4s}
.ov-mark[data-variant=network] .g{animation-duration:2.4s}
.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}
.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}
-.ov-mark[data-variant=stream] .c{stroke-dasharray:60 53;animation-duration:2.8s}
-.ov-mark[data-variant=codes] .v{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}
-.ov-mark[data-variant=blog] .c,.ov-mark[data-variant=wiki] .c,.ov-mark[data-variant=news] .c{animation-duration:6s}
+.ov-mark[data-variant=stream] .sw{stroke-dasharray:20 80;animation-duration:2.4s}
+.ov-mark[data-variant=codes] .lp{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}
+.ov-mark[data-variant=blog] .sw,.ov-mark[data-variant=wiki] .sw,.ov-mark[data-variant=news] .sw{animation-duration:6s}
.ov-mark[data-variant=vip]{--ovm-dot:#facc15}
.ov-mark[data-variant=vip]{--ovm-dot:#facc15}
.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}
.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}
-.ov-mark[data-variant=trade] .c{animation-timing-function:cubic-bezier(.4,0,.2,1)}
+.ov-mark[data-variant=trade] .sw{animation-timing-function:cubic-bezier(.4,0,.2,1)}
.ov-mark[data-variant=host] svg{animation:none}
.ov-mark[data-variant=host] svg{animation:none}
-@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.7}50%{transform:scale(1.7);opacity:1}}
+@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.75}50%{transform:scale(1.6);opacity:1}}
@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
-.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}
-.ov-mark .o2{opacity:.5}
.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
-.ov-mark:hover .c{animation-duration:.9s}
-.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}
-.ov-mark[data-static] .o{display:none}
-.ov-mark[data-ov]:not([data-ovm-run]) svg,.ov-mark[data-ov]:not([data-ovm-run]) .g,.ov-mark[data-ov]:not([data-ovm-run]) .c,.ov-mark[data-ov]:not([data-ovm-run]) .d{animation-play-state:paused!important}
-.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .v{animation:none!important;stroke-dashoffset:0}
-.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}
+.ov-mark:hover .sw,a:hover>.ov-mark .sw{animation-duration:1s}
+.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .lp,.ov-mark[data-static] .sw,.ov-mark[data-static] .d{animation:none!important}
+.ov-mark[data-static] .sw{display:none}
+.ov-mark[data-ov]:not([data-ovm-run]) svg,.ov-mark[data-ov]:not([data-ovm-run]) .g,.ov-mark[data-ov]:not([data-ovm-run]) .sw,.ov-mark[data-ov]:not([data-ovm-run]) .d{animation-play-state:paused!important}
+.ov-mark[data-ov]:not([data-ovm-run]) .sw{opacity:0;transition:opacity .4s}
+.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .lp{animation:none!important;stroke-dashoffset:0}
+.ov-mark .p{fill:none;stroke:currentColor;stroke-width:3.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:138.2;stroke-dashoffset:calc(138.2px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}
.ov-mark[data-progress] .p{opacity:1}
.ov-mark[data-progress] .p{opacity:1}
-.ov-mark[data-progress] .c,.ov-mark[data-progress] .o{opacity:.25}
-.ov-mark[data-state=busy] .c{animation-duration:1.1s}
+.ov-mark[data-progress] .sw{opacity:0}
+.ov-mark[data-state=busy] .sw{animation-duration:1s}
.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}
.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}
.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}
.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}
.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
-@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}
@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
-@keyframes ovmComet{to{transform:rotate(360deg)}}
-@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}
-@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}
-@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}
-.ov-mark .sh,.ov-mark .sw{display:none}
-html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw{display:inline}
-.ov-mark .sr{fill:none;stroke:currentColor;stroke-width:2;opacity:.28}
+@keyframes ovmDraw{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}
+@keyframes ovmSweep{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}
+@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.45);opacity:1}}
+@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.15)}to{transform:rotate(360deg) scale(1)}}
+.ov-mark .sh,.ov-mark .sw2{display:none}
+html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw2{display:inline}
+html[data-ov-season-mark=halloween] .ov-mark .d{display:none}
.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:3.2;stroke-linecap:round}
.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:3.2;stroke-linecap:round}
.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 70%}
.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 70%}
-.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:5.4;stroke-linecap:round}
+.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:4.6;stroke-linecap:round}
.ov-mark .sd{fill:#f4fbff}
.ov-mark .sd{fill:#f4fbff}
-html[data-ov-season-mark=halloween] .ov-mark .o{fill:#ffc27a}
+html[data-ov-season-mark=halloween] .ov-mark{--ovm-to:#f97316}
html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) both}
html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) both}
-.ov-mark[data-ovm-flourish] .sr{animation:ovmFlicker 1.6s linear 1}
.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}
.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}
.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
+.ov-mark[data-ovm-flourish] .sw{animation:ovmSweep .9s linear 1;opacity:.9}
.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}
.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}
.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}
.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}
html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}
@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}
@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
-@keyframes ovmFlicker{0%,100%{opacity:.28}12%{opacity:.7}22%{opacity:.2}38%{opacity:.8}55%{opacity:.35}72%{opacity:.65}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}80%{opacity:.9}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}80%{opacity:.9}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
-@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d,.ov-mark .sl,.ov-mark .sr,.ov-mark .sd{animation:none!important}.ov-mark .o,.ovm-fall{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;
+@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .lp,.ov-mark .sw,.ov-mark .d,.ov-mark .sl,.ov-mark .sd{animation:none!important}.ov-mark .sw,.ovm-fall{display:none}}
+html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
+ // The open loop: from the opening's left end round the bottom to its right end, then down the V and back up.
+ // pathLength=100 lets the draw-in and the running light be written in hundredths of the stroke.
+ const LOOP = 'M15.2 8.8A17.5 17.5 0 1 0 32.8 8.8L24 30.5Z';
function svg(id) {
function svg(id) {
return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
-<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
-<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
-<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
-<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
-<circle class="p" cx="24" cy="24" r="18"/><g class="sh"><path class="sr" d="M24 6.5C16.5 12 16.5 36 24 41.5M24 6.5C31.5 12 31.5 36 24 41.5"/></g>
-<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
-<g class="sh"><path class="ss" d="M23.4 7.4c.2-2.6 1-4.4 2.6-5.6"/><path class="sl" d="M25.8 3.6c2.2-2.4 5.6-2.6 8-1.2-1.8 2.6-5.2 3.4-8 1.2z"/></g>
-<g class="sw"><path class="sc" d="M12.4 10.2A18 18 0 0 1 35.6 10.2"/><circle class="sd" cx="16.6" cy="9.8" r="1.6"/><circle class="sd" cx="30.6" cy="9.4" r="1.3"/></g>
-<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
-<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
+<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" style="stop-color:var(--ovm-to)"/></linearGradient></defs>
+<circle class="g" cx="24" cy="24" r="22"/>
+<circle class="p" cx="24" cy="24" r="22"/>
+<path class="lp" d="${LOOP}" pathLength="100" stroke="url(#${id}g)"/>
+<path class="sw" d="${LOOP}" pathLength="100"/>
+<circle class="d" cx="24" cy="7.4" r="2.7"/>
+<g class="sh"><path class="ss" d="M24 9.6c.2-2.8 1-4.8 2.7-6.1"/><path class="sl" d="M26.4 4.6c2.3-2.5 5.8-2.7 8.3-1.2-1.9 2.7-5.4 3.5-8.3 1.2z"/></g>
+<g class="sw2"><path class="sc" d="M6.6 15.4A19.4 19.4 0 0 1 14.3 7.2"/><path class="sc" d="M33.7 7.2A19.4 19.4 0 0 1 41.4 15.4"/><circle class="sd" cx="10.4" cy="9" r="1.4"/><circle class="sd" cx="38.2" cy="9.6" r="1.2"/></g></svg>`;
}
}
const INTRO_MS = 8000, LINGER_MS = 1200;
const INTRO_MS = 8000, LINGER_MS = 1200;
const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.24.4",
+ "version": "4.25.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -23,7 +23,8 @@ const OUT = path.join(__dirname, '..', 'icons');
@@ -23,7 +23,8 @@ const OUT = path.join(__dirname, '..', 'icons');
const SERVICES = path.join(contracts, 'manifests', 'services');
const SERVICES = path.join(contracts, 'manifests', 'services');
function serviceIds() {
function serviceIds() {
- return fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).sort();
+ // A page card (live-chat, live-rundown, …) belongs to its site, which has the icons and the manifest.
+ return fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).filter((id) => !social.pageOf(id)).sort();
}
}
function siteOf(id) {
function siteOf(id) {
@@ -207,7 +207,12 @@
@@ -207,7 +207,12 @@
.ovs-grid.is-sites { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ovs-grid.is-sites { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ovs-tile small { -webkit-line-clamp: 1; }
.ovs-tile small { -webkit-line-clamp: 1; }
}
}
-@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar { animation: none; } }`;
+/* Dropped from the navbar's orb (4.25.0): under the bar, from the orb's side, the bar still showing. */
+.ovs.ovs-anchored { top: var(--ovs-top, 0px); background: rgba(3, 6, 12, .45); }
+.ovs.ovs-anchored .ovs-panel { top: 8px; left: clamp(8px, var(--ovs-left, 12px), calc(100vw - min(720px, calc(100vw - 16px)) - 8px)); transform: none; transform-origin: 24px 0; max-height: calc(100dvh - var(--ovs-top, 0px) - 24px); animation: ovs-drop .22s cubic-bezier(.2,1.15,.3,1); }
+@keyframes ovs-drop { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: none; } }
+@media (max-width: 640px) { .ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: auto; max-height: calc(100dvh - var(--ovs-top, 0px) - 12px); border-radius: 0 0 22px 22px; border-top: 0; } }
+@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel { animation: none; } }`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
@@ -350,7 +355,8 @@
@@ -350,7 +355,8 @@
g.items.forEach((it) => {
g.items.forEach((it) => {
if (as === 'chips') {
if (as === 'chips') {
const a = option('a', 'ovs-chip', it);
const a = option('a', 'ovs-chip', it);
- if (ICON[it.icon]) a.insertAdjacentHTML('afterbegin', ICON[it.icon]);
+ // The place's own icon (the navbar's drawn set, never page text), else the menu's.
+ if (it.drawn && /^<svg[\s>]/.test(it.drawn)) a.insertAdjacentHTML('afterbegin', it.drawn); else if (ICON[it.icon]) a.insertAdjacentHTML('afterbegin', ICON[it.icon]);
const s = document.createElement('span'); s.textContent = it.title; a.appendChild(s);
const s = document.createElement('span'); s.textContent = it.title; a.appendChild(s);
box.appendChild(a);
box.appendChild(a);
return;
return;
@@ -375,7 +381,7 @@
@@ -375,7 +381,7 @@
function pagesOf(q) {
function pagesOf(q) {
const n = q.toLowerCase();
const n = q.toLowerCase();
return (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden && (!n || l.label.toLowerCase().includes(n)))
return (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden && (!n || l.label.toLowerCase().includes(n)))
- .slice(0, n ? 4 : 8).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', kind: 'page' })).filter((x) => x.url);
+ .slice(0, n ? 4 : 8).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', drawn: l.icon && typeof ctx.navIcon === 'function' ? ctx.navIcon(l.icon) : '', kind: 'page' })).filter((x) => x.url);
}
}
function actions(q) {
function actions(q) {
const out = [];
const out = [];
@@ -510,6 +516,11 @@
@@ -510,6 +516,11 @@
lastFocus = document.activeElement;
lastFocus = document.activeElement;
tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';
tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';
el.classList.add('is-open');
el.classList.add('is-open');
+ // Opened from the navbar's orb: the menu drops from it, under the bar; otherwise centred.
+ const at = opts.anchor;
+ el.classList.toggle('ovs-anchored', !!at);
+ if (at) { el.style.setProperty('--ovs-top', `${Math.max(0, Math.round(at.top))}px`); el.style.setProperty('--ovs-left', `${Math.max(8, Math.round(at.left))}px`); }
+ try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
input.placeholder = `Search ${here()}, or jump to any site`;
input.placeholder = `Search ${here()}, or jump to any site`;
input.value = opts.q || '';
input.value = opts.q || '';
@@ -526,6 +537,8 @@
@@ -526,6 +537,8 @@
el.classList.remove('is-open');
el.classList.remove('is-open');
document.documentElement.style.overflow = '';
document.documentElement.style.overflow = '';
if (inflight) { inflight.abort(); inflight = null; }
if (inflight) { inflight.abort(); inflight = null; }
+ if (ctx && typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }
+ try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: false } })); } catch { /* */ }
if (lastFocus && lastFocus.focus) lastFocus.focus();
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
}
Binary files a/social/actor.jpg and b/social/actor.jpg differ
Binary files a/social/actor.jpg and b/social/actor.jpg differ
Binary files a/social/ai.jpg and b/social/ai.jpg differ
Binary files a/social/ai.jpg and b/social/ai.jpg differ
Binary files a/social/blog.jpg and b/social/blog.jpg differ
Binary files a/social/blog.jpg and b/social/blog.jpg differ
Binary files a/social/bot.jpg and b/social/bot.jpg differ
Binary files a/social/bot.jpg and b/social/bot.jpg differ
Binary files a/social/chat.jpg and b/social/chat.jpg differ
Binary files a/social/chat.jpg and b/social/chat.jpg differ
Binary files a/social/codes.jpg and b/social/codes.jpg differ
Binary files a/social/codes.jpg and b/social/codes.jpg differ
Binary files a/social/community.jpg and b/social/community.jpg differ
Binary files a/social/community.jpg and b/social/community.jpg differ
Binary files a/social/coupons.jpg and b/social/coupons.jpg differ
Binary files a/social/coupons.jpg and b/social/coupons.jpg differ
Binary files a/social/deals.jpg and b/social/deals.jpg differ
Binary files a/social/deals.jpg and b/social/deals.jpg differ
Binary files a/social/events.jpg and b/social/events.jpg differ
Binary files a/social/events.jpg and b/social/events.jpg differ
Binary files a/social/food.jpg and b/social/food.jpg differ
Binary files a/social/food.jpg and b/social/food.jpg differ
Binary files a/social/games.jpg and b/social/games.jpg differ
Binary files a/social/games.jpg and b/social/games.jpg differ
Binary files a/social/help.jpg and b/social/help.jpg differ
Binary files a/social/help.jpg and b/social/help.jpg differ
Binary files a/social/host.jpg and b/social/host.jpg differ
Binary files a/social/host.jpg and b/social/host.jpg differ
Binary files a/social/inventory.jpg and b/social/inventory.jpg differ
Binary files a/social/inventory.jpg and b/social/inventory.jpg differ
Binary files a/social/live-chat.jpg and b/social/live-chat.jpg differ
Binary files a/social/live-chat.jpg and b/social/live-chat.jpg differ
Binary files a/social/live-content.jpg and b/social/live-content.jpg differ
Binary files a/social/live-content.jpg and b/social/live-content.jpg differ
Binary files a/social/live-moments.jpg and b/social/live-moments.jpg differ
Binary files a/social/live-moments.jpg and b/social/live-moments.jpg differ
Binary files a/social/live-rundown.jpg and b/social/live-rundown.jpg differ
Binary files a/social/live-rundown.jpg and b/social/live-rundown.jpg differ
Binary files a/social/live.jpg and b/social/live.jpg differ
Binary files a/social/live.jpg and b/social/live.jpg differ
Binary files a/social/media-hub.jpg and b/social/media-hub.jpg differ
Binary files a/social/media-hub.jpg and b/social/media-hub.jpg differ
Binary files a/social/media.jpg and b/social/media.jpg differ
Binary files a/social/media.jpg and b/social/media.jpg differ
Binary files a/social/network.jpg and b/social/network.jpg differ
Binary files a/social/network.jpg and b/social/network.jpg differ
Binary files a/social/news.jpg and b/social/news.jpg differ
Binary files a/social/news.jpg and b/social/news.jpg differ
Binary files a/social/openre.jpg and b/social/openre.jpg differ
Binary files a/social/openre.jpg and b/social/openre.jpg differ
Binary files a/social/play.jpg and b/social/play.jpg differ
Binary files a/social/play.jpg and b/social/play.jpg differ
Binary files a/social/quest.jpg and b/social/quest.jpg differ
Binary files a/social/quest.jpg and b/social/quest.jpg differ
Binary files a/social/rent.jpg and b/social/rent.jpg differ
Binary files a/social/rent.jpg and b/social/rent.jpg differ
Binary files a/social/reviews.jpg and b/social/reviews.jpg differ
Binary files a/social/reviews.jpg and b/social/reviews.jpg differ
Binary files a/social/run.jpg and b/social/run.jpg differ
Binary files a/social/run.jpg and b/social/run.jpg differ
Binary files a/social/search.jpg and b/social/search.jpg differ
Binary files a/social/search.jpg and b/social/search.jpg differ
Binary files a/social/services.jpg and b/social/services.jpg differ
Binary files a/social/services.jpg and b/social/services.jpg differ
Binary files a/social/space.jpg and b/social/space.jpg differ
Binary files a/social/space.jpg and b/social/space.jpg differ
Binary files a/social/tips.jpg and b/social/tips.jpg differ
Binary files a/social/tips.jpg and b/social/tips.jpg differ
Binary files a/social/tools.jpg and b/social/tools.jpg differ
Binary files a/social/tools.jpg and b/social/tools.jpg differ
Binary files a/social/trade.jpg and b/social/trade.jpg differ
Binary files a/social/trade.jpg and b/social/trade.jpg differ
Binary files a/social/vip.jpg and b/social/vip.jpg differ
Binary files a/social/vip.jpg and b/social/vip.jpg differ
Binary files a/social/watch.jpg and b/social/watch.jpg differ
Binary files a/social/watch.jpg and b/social/watch.jpg differ
Binary files a/social/wiki.jpg and b/social/wiki.jpg differ
Binary files a/social/wiki.jpg and b/social/wiki.jpg differ
Binary files a/social/work.jpg and b/social/work.jpg differ
Binary files a/social/work.jpg and b/social/work.jpg differ
Binary files a/social/zone.jpg and b/social/zone.jpg differ
Binary files a/social/zone.jpg and b/social/zone.jpg differ
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 37.5 * 1024; // 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)
+const NAVBAR_BROTLI_BUDGET = 39.5 * 1024; // 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -76,6 +76,8 @@ const admin = { id: 1, username: 'alex', display_name: 'Alex', role: 'admin' };
@@ -76,6 +76,8 @@ const admin = { id: 1, username: 'alex', display_name: 'Alex', role: 'admin' };
if (name === 'fa-user-secret') continue; // "Switch to Anonymous": only where openvibe.network's account switcher runs
if (name === 'fa-user-secret') continue; // "Switch to Anonymous": only where openvibe.network's account switcher runs
if (name === 'fa-chevron-down') continue; // the account menu's More: only past eight places (4.23.0)
if (name === 'fa-chevron-down') continue; // the account menu's More: only past eight places (4.23.0)
if (name === 'fa-bell') continue; // the Notifications place: only where the bar has no bell (4.23.0)
if (name === 'fa-bell') continue; // the Notifications place: only where the bar has no bell (4.23.0)
+ if (name === 'fa-comment-dots') continue; // messages beside the bell: only for a site that has them (4.25.0)
+ if (name === 'fa-wallet') continue; // the Vibes balance in the menu, drawn when it first opens (4.25.0)
assert.ok(out.includes(glyph(name)), `${name} drawn inline by the default navbar`);
assert.ok(out.includes(glyph(name)), `${name} drawn inline by the default navbar`);
}
}
assert.ok(!/data-ovnav-ic|fa-solid/.test(out), 'no lazy slot or icon-font fallback in the default navbar');
assert.ok(!/data-ovnav-ic|fa-solid/.test(out), 'no lazy slot or icon-font fallback in the default navbar');
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
// name was drawn under the launcher and the magnifier (the owner's screenshot, 2026-10-28): the right-hand group
// name was drawn under the launcher and the magnifier (the owner's screenshot, 2026-10-28): the right-hand group
// (magnifier, balances, bell, picture, menu) left the brand ~19px and the name did not clip. Now, from 320px to 430px:
// (magnifier, balances, bell, picture, menu) left the brand ~19px and the name did not clip. Now, from 320px to 430px:
// nothing in the bar overlaps or runs off the screen, the site's word reads whole from 360px, the folded balances
// nothing in the bar overlaps or runs off the screen, the site's word reads whole from 360px, the folded balances
-// show the first one, and a signed-in phone keeps the launcher in the menu. Skipped without Chrome.
+// show the first one, and a signed-in phone keeps the launcher in the menu. Since 4.25.0 both balances show at every
+// width and the menu's orb sits beside the brand. Skipped without Chrome.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -70,7 +71,9 @@ const MEASURE = `(() => {
@@ -70,7 +71,9 @@ const MEASURE = `(() => {
const last = v.parts[v.parts.length - 1];
const last = v.parts[v.parts.length - 1];
assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);
assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);
if (width >= 360) assert.ok(!v.clipped && /Games/.test(v.visibleText), `${at}: the site's word reads whole (${v.visibleText})`);
if (width >= 360) assert.ok(!v.clipped && /Games/.test(v.visibleText), `${at}: the site's word reads whole (${v.visibleText})`);
- if (who === 'in') assert.strictEqual(v.minis, width <= 440 ? 1 : 2, `${at}: the folded balances show ${width <= 440 ? 'the first one' : 'both'}`);
+ if (who === 'in') assert.strictEqual(v.minis, 2, `${at}: the folded balances show both, OpenCoins and Vibes (4.25.0)`);
+ const orb = v.parts.find(([k]) => k === 'search');
+ assert.ok(orb && orb[1][0] < width / 2, `${at}: the OpenVibe menu's orb sits by the brand, on the left (4.25.0)`);
assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: no launcher button: the magnifier opens the one menu (4.23.0)`);
assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: no launcher button: the magnifier opens the one menu (4.23.0)`);
await p.close();
await p.close();
}
}
@@ -110,7 +110,7 @@ const hrefs = (html) => [...html.matchAll(/href="([^"]+)"/g)].map((m) => unescap
@@ -110,7 +110,7 @@ const hrefs = (html) => [...html.matchAll(/href="([^"]+)"/g)].map((m) => unescap
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
const menu = made.find((e) => e.className === 'openvibe-navbar-dropdown');
const menu = made.find((e) => e.className === 'openvibe-navbar-dropdown');
assert.ok(/data-account-id="8"/.test(menu.innerHTML), 'in place');
assert.ok(/data-account-id="8"/.test(menu.innerHTML), 'in place');
- assert.ok(/Switch to Anonymous/.test(menu.innerHTML));
+ assert.ok(/data-account-id="anon"[^>]*title="Browse without your name"[\s\S]*?<span>Anonymous<\/span>/.test(menu.innerHTML), 'the Anonymous pill, whole (4.25.0)');
const add = new URL(hrefs(menu.innerHTML).find((h) => h.includes('add_account=')));
const add = new URL(hrefs(menu.innerHTML).find((h) => h.includes('add_account=')));
assert.strictEqual(add.searchParams.get('return'), 'https://openvibe.network/my', 'on the Network the page itself is the sign-in');
assert.strictEqual(add.searchParams.get('return'), 'https://openvibe.network/my', 'on the Network the page itself is the sign-in');
}
}
@@ -36,14 +36,14 @@ function load(store, answers) {
@@ -36,14 +36,14 @@ function load(store, answers) {
const calls = [];
const calls = [];
global.fetch = async (url) => {
global.fetch = async (url) => {
calls.push(url);
calls.push(url);
- const a = /\/api\/me\/vibes$/.test(url) ? answers.vibes : /\/api\/coins\/me$/.test(url) ? answers.coins : { status: 404, body: {} };
+ const a = /\/api\/me\/vibes$/.test(url) ? answers.vibes : /\/api\/coins\/me(\?|$)/.test(url) ? answers.coins : { status: 404, body: {} };
return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
return { ok: a.status >= 200 && a.status < 300, status: a.status, json: async () => a.body };
};
};
delete require.cache[require.resolve(NAVBAR_PATH)];
delete require.cache[require.resolve(NAVBAR_PATH)];
return { navbar: require(NAVBAR_PATH), navs, calls };
return { navbar: require(NAVBAR_PATH), navs, calls };
}
}
const settle = () => new Promise((r) => setTimeout(r, 20));
const settle = () => new Promise((r) => setTimeout(r, 20));
-const wallet = (calls) => calls.filter((u) => /\/api\/(coins\/me|me\/vibes)$/.test(u));
+const wallet = (calls) => calls.filter((u) => /\/api\/(coins\/me|me\/vibes)(\?|$)/.test(u));
const chip = (html, id) => (new RegExp(`<button[^>]*data-chip-id="${id}"[^>]*>`).exec(html) || [''])[0];
const chip = (html, id) => (new RegExp(`<button[^>]*data-chip-id="${id}"[^>]*>`).exec(html) || [''])[0];
const user = { id: 7, subject: 'usr_7', username: 'maya' };
const user = { id: 7, subject: 'usr_7', username: 'maya' };
const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.network', user, token: 'tok', wallet: true };
const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.network', user, token: 'tok', wallet: true };
@@ -57,7 +57,8 @@ const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.ne
@@ -57,7 +57,8 @@ const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.ne
navbar.init(base);
navbar.init(base);
assert.match(chip(navs[0].innerHTML, 'coins'), /hidden/, 'nothing to show before the balances arrive');
assert.match(chip(navs[0].innerHTML, 'coins'), /hidden/, 'nothing to show before the balances arrive');
await settle();
await settle();
- assert.deepStrictEqual(wallet(calls).sort(), ['https://billing.openvibe.network/api/me/vibes', 'https://openvibe.network/api/coins/me']);
+ // An older Network answers the coins only: Vibes come from Billing itself (4.25.0 asks the Network for both first).
+ assert.deepStrictEqual(wallet(calls).sort(), ['https://billing.openvibe.network/api/me/vibes', 'https://openvibe.network/api/coins/me?with=vibes']);
const last = navs[navs.length - 1].innerHTML;
const last = navs[navs.length - 1].innerHTML;
assert.ok(navs.length >= 2, 'drawn again once the chips appeared');
assert.ok(navs.length >= 2, 'drawn again once the chips appeared');
assert.doesNotMatch(chip(last, 'coins'), /hidden/);
assert.doesNotMatch(chip(last, 'coins'), /hidden/);
@@ -67,6 +68,20 @@ const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.ne
@@ -67,6 +68,20 @@ const base = { service: 'games', auth: 'external', apiBase: 'https://openvibe.ne
assert.match(last, /aria-label="Your balances: OpenCoins 160,774, Vibes 2,500"/);
assert.match(last, /aria-label="Your balances: OpenCoins 160,774, Vibes 2,500"/);
assert.deepStrictEqual(JSON.parse(store['ov-wallet:usr_7']), { coins: '160,774', vibes: '2,500' });
assert.deepStrictEqual(JSON.parse(store['ov-wallet:usr_7']), { coins: '160,774', vibes: '2,500' });
+ // A Network that answers both (4.25.0): one request, Billing never asked (no site's connect-src allowed it).
+ {
+ const store2 = {};
+ const both = { coins: { status: 200, body: { balance: 160774, vibes: 2500 } }, vibes: { status: 500, body: {} } };
+ const r = load(store2, both);
+ r.navbar.init(base);
+ await settle();
+ assert.deepStrictEqual(wallet(r.calls), ['https://openvibe.network/api/coins/me?with=vibes'], 'one request for both balances');
+ assert.match(r.navs[r.navs.length - 1].innerHTML, /data-chip-id="vibes"[^>]*>[\s\S]*?2,500/);
+ }
+ ({ navbar, navs, calls } = load(store, answers));
+ navbar.init(base);
+ await settle();
+
// Drawn again on the same page within a minute: no second request.
// Drawn again on the same page within a minute: no second request.
const before = wallet(calls).length;
const before = wallet(calls).length;
navbar.init(base);
navbar.init(base);
@@ -28,13 +28,15 @@ function dropdown() {
@@ -28,13 +28,15 @@ function dropdown() {
configure({ token: 'tok', apiBase: 'https://openvibe.network', menu: null });
configure({ token: 'tok', apiBase: 'https://openvibe.network', menu: null });
let d = dropdown();
let d = dropdown();
await loadWallet(d);
await loadWallet(d);
- assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me', 'https://billing.openvibe.network/api/me/vibes']);
+ // The Network first, for both (4.25.0); an older one answers the coins only, so Billing itself after it.
+ assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes', 'https://billing.openvibe.network/api/me/vibes']);
assert.strictEqual(calls[1].init.headers.Authorization, 'Bearer tok');
assert.strictEqual(calls[1].init.headers.Authorization, 'Bearer tok');
assert.strictEqual(calls[1].init.credentials, 'omit', 'no cookies to Billing');
assert.strictEqual(calls[1].init.credentials, 'omit', 'no cookies to Billing');
assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);
assert.strictEqual(d.els['#openvibe-wallet'].hidden, false);
assert.match(d.els['#openvibe-wallet'].innerHTML, /1,234/);
assert.match(d.els['#openvibe-wallet'].innerHTML, /1,234/);
assert.strictEqual(d.els['#openvibe-vibes'].hidden, false);
assert.strictEqual(d.els['#openvibe-vibes'].hidden, false);
- assert.match(d.els['#openvibe-vibes'].innerHTML, /5,600 Vibes/);
+ assert.match(d.els['#openvibe-vibes'].innerHTML, /5,600 <small>Vibes<\/small>/);
+ assert.match(d.els['#openvibe-wallet'].innerHTML, /1,234 <small>OpenCoins<\/small>/, 'each balance says what it is');
assert.match(d.els['#openvibe-vibes'].innerHTML, /href="https:\/\/openvibe\.live\/#vibes"/);
assert.match(d.els['#openvibe-vibes'].innerHTML, /href="https:\/\/openvibe\.live\/#vibes"/);
await loadWallet(d);
await loadWallet(d);
@@ -61,6 +63,16 @@ function dropdown() {
@@ -61,6 +63,16 @@ function dropdown() {
await loadWallet(dropdown());
await loadWallet(dropdown());
assert.strictEqual(calls.length, 0);
assert.strictEqual(calls.length, 0);
+ // A Network that answers both: one request, Billing never asked.
+ configure({ token: 'tok', menu: null, wallet: false, chips: [] });
+ answer.coins = { status: 200, body: { balance: 1234, vibes: 77 } };
+ calls.length = 0;
+ d = dropdown();
+ await loadWallet(d);
+ assert.deepStrictEqual(calls.map((c) => c.url), ['https://openvibe.network/api/coins/me?with=vibes']);
+ assert.match(d.els['#openvibe-vibes'].innerHTML, /77 <small>Vibes<\/small>/);
+ answer.coins = { status: 200, body: { balance: 1234 } };
+
// billingBase points elsewhere (a test cell).
// billingBase points elsewhere (a test cell).
configure({ token: 'tok', billingBase: 'https://billing.example.test' });
configure({ token: 'tok', billingBase: 'https://billing.example.test' });
calls.length = 0;
calls.length = 0;
@@ -70,7 +70,7 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, htm
@@ -70,7 +70,7 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, htm
{
{
const t = setup();
const t = setup();
const css = t.document.getElementById('ov-mark-css').textContent;
const css = t.document.getElementById('ov-mark-css').textContent;
- assert.match(css, /\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) svg,[^{]*\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) \.c,[^{]*\{animation-play-state:paused!important\}/, 'paused rule');
+ assert.match(css, /\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) svg,[^{]*\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) \.sw,[^{]*\{animation-play-state:paused!important\}/, 'paused rule (the running light, 4.25.0)');
assert.ok(t.mark.querySelector('svg'), 'mounted');
assert.ok(t.mark.querySelector('svg'), 'mounted');
assert.ok(t.io.targets.has(t.mark), 'observed for visibility');
assert.ok(t.io.targets.has(t.mark), 'observed for visibility');
// Not yet known to be on screen: still, SMIL parked on the centred V sweep.
// Not yet known to be on screen: still, SMIL parked on the centred V sweep.