openvibers/openvibe.shared Commits
4.22.1: search suggestions on every site (through Network) and a navbar that fits a phone signed in (#147)
Changes
@@@ -6,6 +6,23 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.22.1 — 2026-10-28++++- **Search suggestions reach every site.** `search.js` asked `search.openvibe.network` from the page, and the++ `connect-src` of 33 sites refused it (Live among them): the magnifier opened but suggested nothing. Suggestions now++ come through OpenVibe.Network's `GET /api/frame/suggest`. That route asks Search, keeps answers 30 s and keeps++ Search's limit per person; every site already allows `openvibe.network` for the navbar.++ `OpenVibeSearch.suggest(q, { url })` takes either address.++- **The navbar fits a phone, signed in.** On openvibe.games the site's name was drawn under the launcher and the++ magnifier: the right-hand group (magnifier, balances, bell, picture, menu) left the brand about 19 px, and the name++ never clipped. The changes:++ - **The name:** it gives way with an ellipsis, and only the site's word shows up to 480 px (was 420).++ - **Balances:** up to 440 px the folded balances show the first one; a tap lists all.++ - **The launcher:** a signed-in phone (and every phone under 375 px) keeps it in the menu's "All sites".++ - **Spacing:** the right-hand buttons sit 2 px apart under 400 px.++++ New `test/navbar-phone-fit-chrome.test.js` covers 320–430 px, signed in and out.++## 4.22.0 — 2026-10-28- **A tidier navbar** (the owner, 2026-10-28: "reanalyze all the nav bars across all the sites… redundant feeling@@@ -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.22.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.22.1 (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.22.0",++ "version": "4.22.1","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)",@@@ -226,6 +226,17 @@ OpenVibeNavbar.init({ service: 'food', links: [], appBar: true, search: { owner: 'food', label: 'OpenVibe.Food' } })```++**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++ 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.++++`test/navbar-phone-fit-chrome.test.js` measures the bar from 320 to 430 px, signed in and out.++## The app bar```js@@@ -252,8 +263,8 @@ The navbar has a magnifier on every site. `/` or Ctrl+K opens it too. The firstWhat it does:- **Before typing:** the site's own pages, and what you searched before on this device.--- **While typing:** suggestions from OpenVibe.Search for every site (`GET /api/v1/suggest`, anonymous, the older-- request cancelled when you type on), this site's results first.++- **While typing:** suggestions from OpenVibe.Search for every site, this site's results first. They are anonymous, and++ the older request is cancelled when you type on.- **Always offered:** the site's own search page (when it has one), "Search all of OpenVibe" and "Ask Vibe".- **Keys:** arrows move, Enter opens, Escape closes.- **Layout:** a phone gets the whole screen; a computer gets a panel near the top.@@@ -264,8 +275,13 @@ OpenVibeNavbar.openSearch('seattle') // open it from anythinOpenVibeSearch.suggest('sea', { owner: 'live' }) // the same suggestions for a page's own box```--Search answers any origin (anonymous, never with credentials). So the same suggestions work from anyone's page, and--from the SDK: `openvibe-sdk/search`'s `search.suggest(text, { owner, type, limit })` and++The suggestions come through OpenVibe.Network: `GET https://openvibe.network/api/frame/suggest?q=&owner=&limit=`++asks OpenVibe.Search's `GET /api/v1/suggest` and keeps answers for 30 seconds. A site's Content-Security-Policy++already lets the navbar reach `openvibe.network`, so a site needs nothing new in its `connect-src`.++++Both paths answer any origin (anonymous, never with credentials). So the same suggestions work from anyone's page:++pass `{ url: 'https://search.openvibe.network/api/v1/suggest' }` where your `connect-src` allows Search itself. The++SDK has them too: `openvibe-sdk/search`'s `search.suggest(text, { owner, type, limit })` and`search.query(text, { owner, type })`.## Loading@@@ -145,6 +145,17 @@}@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }++ /* A narrow phone never draws the site's name over the bar's buttons (owner, 2026-10-28: on openvibe.games, signed++ in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis; up to 440px the++ folded balances show the first one only (a tap lists them all), and a signed-in bar (balances, bell, picture) lets++ the launcher step into the menu, whose "All sites" opens it (everyone's under 375px). */++ .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: 440px) { .openvibe-navbar:has(#openvibe-avatar-btn) .ovnav-launch { display: none; } }++ @media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 2px; } }++ @media (max-width: 374px) { .openvibe-navbar .ovnav-launch { display: none; } }.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; box-sizing: border-box; width: min(760px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 0 12px 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }.ovnav-launcher .ovl-top { position: sticky; top: 0; z-index: 1; margin: 0 -12px; padding: 12px 12px 2px; background: inherit; }@@@ -204,7 +215,7 @@.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }}-- @media (max-width: 420px) {++ @media (max-width: 480px) {.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-core,.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-dot { display: none; }.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.22.0",++ "version": "4.22.1","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -5,14 +5,18 @@* the SDK and API… intelligent and aware").** What it knows: the site it is on (its pages come first, its own search page when it has one), what you searched-- * before (on this device), and everything OpenVibe.Search holds across the sites, suggested as you type (the public-- * GET /api/v1/suggest, anonymous, one request in flight, the older one cancelled). Every row is a link: a result, a++ * before (on this device), and everything OpenVibe.Search holds across the sites, suggested as you type (anonymous,++ * one request in flight, the older one cancelled). The suggestions come through OpenVibe.Network's++ * GET /api/frame/suggest (OpenVibe.Search's /api/v1/suggest, kept 30 s): every site's Content-Security-Policy already++ * lets the navbar reach openvibe.network, and most refuse search.openvibe.network. Every row is a link: a result, a* page of this site, "Search all of OpenVibe" and "Ask Vibe". Arrows move, Enter opens, Escape closes. A phone gets* the whole screen; a computer a panel near the top.*-- * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q }) navbar.js calls it++ * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q, suggestUrl }) navbar.js calls it* OpenVibeSearch.close()-- * OpenVibeSearch.suggest(q, { owner, limit, signal }) → [{ owner, type, id, title, url, site }] for any page's own box++ * OpenVibeSearch.suggest(q, { owner, limit, signal, url }) → [{ owner, type, id, title, url, site }] for any page's own box++ * (url: https://openvibe.network/api/frame/suggest, or https://search.openvibe.network/api/v1/suggest where the++ * page's connect-src allows it; both answer any origin)** Text is always set with textContent; links are https or same-site paths only. Recent searches live in localStorage* (ov_search_recent, eight at most) and can be cleared from the panel.@@@ -20,6 +24,7 @@(function (root) {'use strict';const API = 'https://search.openvibe.network';++ const SUGGEST = 'https://openvibe.network/api/frame/suggest';const RECENT_KEY = 'ov_search_recent';const SITE = { 'openre.stream': 'Restream', 'search.openvibe.network': 'Search', 'ai.openvibe.services': 'AI' };const TYPE = { vod: 'Recording', clip: 'Clip', job: 'Job', story: 'Story', page: 'Page', thread: 'Thread', paste: 'Paste', post: 'Post', stream: 'Live', channel: 'Channel', deal: 'Deal', coupon: 'Code', review: 'Review', entity: 'Reviews', recipe: 'Recipe', game: 'Game', tool: 'Tool', listing: 'Listing', wiki: 'Wiki' };@@@ -41,8 +46,8 @@clear() { try { localStorage.removeItem(RECENT_KEY); } catch { /* */ } },};-- async function suggest(q, { owner = null, limit = 8, signal } = {}) {-- const u = new URL('/api/v1/suggest', API);++ async function suggest(q, { owner = null, limit = 8, signal, url = SUGGEST } = {}) {++ const u = new URL(url || SUGGEST);u.searchParams.set('q', String(q).slice(0, 100));if (owner) u.searchParams.set('owner', owner);u.searchParams.set('limit', String(limit));@@@ -217,7 +222,7 @@const ac = new AbortController();inflight = ac;el.classList.add('ovs-busy');-- suggest(q, { limit: 10, signal: ac.signal }).then((found) => {++ suggest(q, { limit: 10, signal: ac.signal, url: ctx.suggestUrl }).then((found) => {if (ac.signal.aborted) return;const owner = ctx.scope && ctx.scope.owner;const row = (s) => ({ title: s.title, meta: [s.site, TYPE[s.type] || s.type].filter(Boolean).join(' · '), url: s.url, icon: (s.site || '?').slice(0, 2), kind: 'result' });nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++// The navbar on a phone, signed in with balances (4.22.1), in a real headless Chrome. On openvibe.games the site's++// 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.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('navbar-phone-fit-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const PAGE = (signedIn) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Games · OpenVibe.Games</title><link rel="icon" href="data:,"></head>++<body><div id="navbar-mount"></div><main><h1>Games</h1></main>++<script>++ localStorage.setItem('ov-wallet:42', JSON.stringify({ coins: 4100, vibes: 1250 }));++ window.fetch = async () => new Response('{}', { status: 404 });++</script>++<script src="/navbar.js"></script>++<script>++ OpenVibeNavbar.init({ service: 'games', brandName: 'OpenVibe.Games', wallet: true, ${signedIn ? "token: 'x', user: { id: 42, username: 'moosely', display_name: 'moosely', avatar_url: null }," : 'user: null,'}++ links: [{ label: 'Games', href: '/', icon: 'fa-gamepad' }, { label: 'Leaderboard', href: '/top', icon: 'fa-star' }] });++</script></body></html>`;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/in' || u.pathname === '/out') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE(u.pathname === '/in')); }++ const name = u.pathname.slice(1);++ if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }++ res.writeHead(404); return res.end();++});++++const MEASURE = `(() => {++ const bar = document.querySelector('.openvibe-navbar');++ const vis = (el) => el && getComputedStyle(el).display !== 'none' && el.getBoundingClientRect().width > 0;++ const box = (el) => { const r = el.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right)]; };++ const name = bar.querySelector('.openvibe-navbar-brand .name');++ const parts = [['name', name], ['launcher', bar.querySelector('.ovnav-launch')], ['search', bar.querySelector('#openvibe-search-btn')],++ ['balances', bar.querySelector('.ovnav-chip--all')], ['sign in', bar.querySelector('#openvibe-login-btn')],++ ['picture', bar.querySelector('#openvibe-avatar-btn')], ['menu', bar.querySelector('#openvibe-burger')]].filter(([, el]) => vis(el)).map(([k, el]) => [k, box(el)]);++ const minis = [...bar.querySelectorAll('.ovnav-chip--all .ovnav-chip-mini')].filter(vis).length;++ return { parts, clipped: name.scrollWidth > name.clientWidth + 1, visibleText: name.innerText, minis, width: innerWidth };++})()`;++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-navfit-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ try {++ const context = await browser.newContext();++ for (const width of [320, 360, 393, 412, 430]) {++ for (const who of ['in', 'out']) {++ const p = await h.openPage(browser, context, { width });++ await p.goto(`${base}/${who}`, { maxMs: 5000 });++ await wait(300);++ const v = await p.evaluate(MEASURE);++ const at = `${width}px signed ${who}`;++ for (let i = 1; i < v.parts.length; i++) {++ const [a, ra] = v.parts[i - 1]; const [b, rb] = v.parts[i];++ assert.ok(rb[0] >= ra[1] - 1, `${at}: ${a} ${JSON.stringify(ra)} runs into ${b} ${JSON.stringify(rb)}`);++ }++ 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, 1, `${at}: the folded balances show the first one`);++ assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: a signed-in phone keeps the launcher in the menu`);++ } else if (width >= 375) {++ assert.ok(v.parts.some(([k]) => k === 'launcher'), `${at}: signed out, the launcher stays by the name`);++ }++ await p.close();++ }++ }++ console.log('navbar-phone-fit-chrome: signed in or out, 320-430px, nothing overlaps; the name reads whole from 360px ok');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -22,11 +22,13 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title><body><div id="navbar-mount"></div><main><div id="player" style="position:relative;width:360px;height:202px"></div><div id="list"></div></main><script src="/navbar.js"></script><script src="/loading.js"></script><script>-- // search.js asks the real Search; this page answers instead (same shape), slowly for "slow".++ // search.js asks Search through Network's /api/frame/suggest (every site's connect-src allows openvibe.network); this++ // page answers instead (same shape), slowly for "slow". Asking search.openvibe.network itself is a failure here.window.__realFetch = window.fetch;window.fetch = async (u, init) => {const url = new URL(String(u), location.href);-- if (url.hostname === 'search.openvibe.network') return window.__realFetch('/suggest' + url.search, init);++ if (url.hostname === 'openvibe.network' && url.pathname === '/api/frame/suggest') return window.__realFetch('/suggest' + url.search, init);++ if (url.hostname === 'search.openvibe.network') throw new TypeError('refused by connect-src');return window.__realFetch(u, init);};OpenVibeNavbar.init({ service: 'chat', user: null, links: [{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Servers', href: '/servers', icon: 'fa-server' }],
@@ -6,6 +6,23 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,23 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.22.1 — 2026-10-28
+
+- **Search suggestions reach every site.** `search.js` asked `search.openvibe.network` from the page, and the
+ `connect-src` of 33 sites refused it (Live among them): the magnifier opened but suggested nothing. Suggestions now
+ come through OpenVibe.Network's `GET /api/frame/suggest`. That route asks Search, keeps answers 30 s and keeps
+ Search's limit per person; every site already allows `openvibe.network` for the navbar.
+ `OpenVibeSearch.suggest(q, { url })` takes either address.
+- **The navbar fits a phone, signed in.** On openvibe.games the site's name was drawn under the launcher and the
+ magnifier: the right-hand group (magnifier, balances, bell, picture, menu) left the brand about 19 px, and the name
+ never clipped. The changes:
+ - **The name:** it gives way with an ellipsis, and only the site's word shows up to 480 px (was 420).
+ - **Balances:** up to 440 px the folded balances show the first one; a tap lists all.
+ - **The launcher:** a signed-in phone (and every phone under 375 px) keeps it in the menu's "All sites".
+ - **Spacing:** the right-hand buttons sit 2 px apart under 400 px.
+
+ New `test/navbar-phone-fit-chrome.test.js` covers 320–430 px, signed in and out.
+
## 4.22.0 — 2026-10-28
## 4.22.0 — 2026-10-28
- **A tidier navbar** (the owner, 2026-10-28: "reanalyze all the nav bars across all the sites… redundant feeling
- **A tidier navbar** (the owner, 2026-10-28: "reanalyze all the nav bars across all the sites… redundant feeling
@@ -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.22.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.22.1 (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.22.0",
+ "version": "4.22.1",
"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)",
@@ -226,6 +226,17 @@ OpenVibeNavbar.init({ service: 'food', links: [
@@ -226,6 +226,17 @@ OpenVibeNavbar.init({ service: 'food', links: [
], appBar: true, search: { owner: 'food', label: 'OpenVibe.Food' } })
], appBar: true, search: { owner: 'food', label: 'OpenVibe.Food' } })
```
```
+**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
+ 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.
+
+`test/navbar-phone-fit-chrome.test.js` measures the bar from 320 to 430 px, signed in and out.
+
## The app bar
## The app bar
```js
```js
@@ -252,8 +263,8 @@ The navbar has a magnifier on every site. `/` or Ctrl+K opens it too. The first
@@ -252,8 +263,8 @@ The navbar has a magnifier on every site. `/` or Ctrl+K opens it too. The first
What it does:
What it does:
- **Before typing:** the site's own pages, and what you searched before on this device.
- **Before typing:** the site's own pages, and what you searched before on this device.
-- **While typing:** suggestions from OpenVibe.Search for every site (`GET /api/v1/suggest`, anonymous, the older
- request cancelled when you type on), this site's results first.
+- **While typing:** suggestions from OpenVibe.Search for every site, this site's results first. They are anonymous, and
+ the older request is cancelled when you type on.
- **Always offered:** the site's own search page (when it has one), "Search all of OpenVibe" and "Ask Vibe".
- **Always offered:** the site's own search page (when it has one), "Search all of OpenVibe" and "Ask Vibe".
- **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.
- **Layout:** a phone gets the whole screen; a computer gets a panel near the top.
@@ -264,8 +275,13 @@ OpenVibeNavbar.openSearch('seattle') // open it from anythin
@@ -264,8 +275,13 @@ OpenVibeNavbar.openSearch('seattle') // open it from anythin
OpenVibeSearch.suggest('sea', { owner: 'live' }) // the same suggestions for a page's own box
OpenVibeSearch.suggest('sea', { owner: 'live' }) // the same suggestions for a page's own box
```
```
-Search answers any origin (anonymous, never with credentials). So the same suggestions work from anyone's page, and
-from the SDK: `openvibe-sdk/search`'s `search.suggest(text, { owner, type, limit })` and
+The suggestions come through OpenVibe.Network: `GET https://openvibe.network/api/frame/suggest?q=&owner=&limit=`
+asks OpenVibe.Search's `GET /api/v1/suggest` and keeps answers for 30 seconds. A site's Content-Security-Policy
+already lets the navbar reach `openvibe.network`, so a site needs nothing new in its `connect-src`.
+
+Both paths answer any origin (anonymous, never with credentials). So the same suggestions work from anyone's page:
+pass `{ url: 'https://search.openvibe.network/api/v1/suggest' }` where your `connect-src` allows Search itself. The
+SDK has them too: `openvibe-sdk/search`'s `search.suggest(text, { owner, type, limit })` and
`search.query(text, { owner, type })`.
`search.query(text, { owner, type })`.
## Loading
## Loading
@@ -145,6 +145,17 @@
@@ -145,6 +145,17 @@
}
}
@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
+ /* A narrow phone never draws the site's name over the bar's buttons (owner, 2026-10-28: on openvibe.games, signed
+ in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis; up to 440px the
+ folded balances show the first one only (a tap lists them all), and a signed-in bar (balances, bell, picture) lets
+ the launcher step into the menu, whose "All sites" opens it (everyone's under 375px). */
+ .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: 440px) { .openvibe-navbar:has(#openvibe-avatar-btn) .ovnav-launch { display: none; } }
+ @media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 2px; } }
+ @media (max-width: 374px) { .openvibe-navbar .ovnav-launch { display: none; } }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; box-sizing: border-box; width: min(760px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 0 12px 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; box-sizing: border-box; width: min(760px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 0 12px 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-top { position: sticky; top: 0; z-index: 1; margin: 0 -12px; padding: 12px 12px 2px; background: inherit; }
.ovnav-launcher .ovl-top { position: sticky; top: 0; z-index: 1; margin: 0 -12px; padding: 12px 12px 2px; background: inherit; }
@@ -204,7 +215,7 @@
@@ -204,7 +215,7 @@
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
}
}
- @media (max-width: 420px) {
+ @media (max-width: 480px) {
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-core,
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-core,
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-dot { display: none; }
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-dot { display: none; }
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.22.0",
+ "version": "4.22.1",
"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": {
@@ -5,14 +5,18 @@
@@ -5,14 +5,18 @@
* the SDK and API… intelligent and aware").
* the SDK and API… intelligent and aware").
*
*
* What it knows: the site it is on (its pages come first, its own search page when it has one), what you searched
* What it knows: the site it is on (its pages come first, its own search page when it has one), what you searched
- * before (on this device), and everything OpenVibe.Search holds across the sites, suggested as you type (the public
- * GET /api/v1/suggest, anonymous, one request in flight, the older one cancelled). Every row is a link: a result, a
+ * before (on this device), and everything OpenVibe.Search holds across the sites, suggested as you type (anonymous,
+ * one request in flight, the older one cancelled). The suggestions come through OpenVibe.Network's
+ * GET /api/frame/suggest (OpenVibe.Search's /api/v1/suggest, kept 30 s): every site's Content-Security-Policy already
+ * lets the navbar reach openvibe.network, and most refuse search.openvibe.network. Every row is a link: a result, a
* page of this site, "Search all of OpenVibe" and "Ask Vibe". Arrows move, Enter opens, Escape closes. A phone gets
* page of this site, "Search all of OpenVibe" and "Ask Vibe". Arrows move, Enter opens, Escape closes. A phone gets
* the whole screen; a computer a panel near the top.
* the whole screen; a computer a panel near the top.
*
*
- * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q }) navbar.js calls it
+ * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q, suggestUrl }) navbar.js calls it
* OpenVibeSearch.close()
* OpenVibeSearch.close()
- * OpenVibeSearch.suggest(q, { owner, limit, signal }) → [{ owner, type, id, title, url, site }] for any page's own box
+ * OpenVibeSearch.suggest(q, { owner, limit, signal, url }) → [{ owner, type, id, title, url, site }] for any page's own box
+ * (url: https://openvibe.network/api/frame/suggest, or https://search.openvibe.network/api/v1/suggest where the
+ * page's connect-src allows it; both answer any origin)
*
*
* Text is always set with textContent; links are https or same-site paths only. Recent searches live in localStorage
* Text is always set with textContent; links are https or same-site paths only. Recent searches live in localStorage
* (ov_search_recent, eight at most) and can be cleared from the panel.
* (ov_search_recent, eight at most) and can be cleared from the panel.
@@ -20,6 +24,7 @@
@@ -20,6 +24,7 @@
(function (root) {
(function (root) {
'use strict';
'use strict';
const API = 'https://search.openvibe.network';
const API = 'https://search.openvibe.network';
+ const SUGGEST = 'https://openvibe.network/api/frame/suggest';
const RECENT_KEY = 'ov_search_recent';
const RECENT_KEY = 'ov_search_recent';
const SITE = { 'openre.stream': 'Restream', 'search.openvibe.network': 'Search', 'ai.openvibe.services': 'AI' };
const SITE = { 'openre.stream': 'Restream', 'search.openvibe.network': 'Search', 'ai.openvibe.services': 'AI' };
const TYPE = { vod: 'Recording', clip: 'Clip', job: 'Job', story: 'Story', page: 'Page', thread: 'Thread', paste: 'Paste', post: 'Post', stream: 'Live', channel: 'Channel', deal: 'Deal', coupon: 'Code', review: 'Review', entity: 'Reviews', recipe: 'Recipe', game: 'Game', tool: 'Tool', listing: 'Listing', wiki: 'Wiki' };
const TYPE = { vod: 'Recording', clip: 'Clip', job: 'Job', story: 'Story', page: 'Page', thread: 'Thread', paste: 'Paste', post: 'Post', stream: 'Live', channel: 'Channel', deal: 'Deal', coupon: 'Code', review: 'Review', entity: 'Reviews', recipe: 'Recipe', game: 'Game', tool: 'Tool', listing: 'Listing', wiki: 'Wiki' };
@@ -41,8 +46,8 @@
@@ -41,8 +46,8 @@
clear() { try { localStorage.removeItem(RECENT_KEY); } catch { /* */ } },
clear() { try { localStorage.removeItem(RECENT_KEY); } catch { /* */ } },
};
};
- async function suggest(q, { owner = null, limit = 8, signal } = {}) {
- const u = new URL('/api/v1/suggest', API);
+ async function suggest(q, { owner = null, limit = 8, signal, url = SUGGEST } = {}) {
+ const u = new URL(url || SUGGEST);
u.searchParams.set('q', String(q).slice(0, 100));
u.searchParams.set('q', String(q).slice(0, 100));
if (owner) u.searchParams.set('owner', owner);
if (owner) u.searchParams.set('owner', owner);
u.searchParams.set('limit', String(limit));
u.searchParams.set('limit', String(limit));
@@ -217,7 +222,7 @@
@@ -217,7 +222,7 @@
const ac = new AbortController();
const ac = new AbortController();
inflight = ac;
inflight = ac;
el.classList.add('ovs-busy');
el.classList.add('ovs-busy');
- suggest(q, { limit: 10, signal: ac.signal }).then((found) => {
+ suggest(q, { limit: 10, signal: ac.signal, url: ctx.suggestUrl }).then((found) => {
if (ac.signal.aborted) return;
if (ac.signal.aborted) return;
const owner = ctx.scope && ctx.scope.owner;
const owner = ctx.scope && ctx.scope.owner;
const row = (s) => ({ title: s.title, meta: [s.site, TYPE[s.type] || s.type].filter(Boolean).join(' · '), url: s.url, icon: (s.site || '?').slice(0, 2), kind: 'result' });
const row = (s) => ({ title: s.title, meta: [s.site, TYPE[s.type] || s.type].filter(Boolean).join(' · '), url: s.url, icon: (s.site || '?').slice(0, 2), kind: 'result' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+// The navbar on a phone, signed in with balances (4.22.1), in a real headless Chrome. On openvibe.games the site's
+// 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.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('navbar-phone-fit-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const PAGE = (signedIn) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Games · OpenVibe.Games</title><link rel="icon" href="data:,"></head>
+<body><div id="navbar-mount"></div><main><h1>Games</h1></main>
+<script>
+ localStorage.setItem('ov-wallet:42', JSON.stringify({ coins: 4100, vibes: 1250 }));
+ window.fetch = async () => new Response('{}', { status: 404 });
+</script>
+<script src="/navbar.js"></script>
+<script>
+ OpenVibeNavbar.init({ service: 'games', brandName: 'OpenVibe.Games', wallet: true, ${signedIn ? "token: 'x', user: { id: 42, username: 'moosely', display_name: 'moosely', avatar_url: null }," : 'user: null,'}
+ links: [{ label: 'Games', href: '/', icon: 'fa-gamepad' }, { label: 'Leaderboard', href: '/top', icon: 'fa-star' }] });
+</script></body></html>`;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/in' || u.pathname === '/out') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE(u.pathname === '/in')); }
+ const name = u.pathname.slice(1);
+ if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }
+ res.writeHead(404); return res.end();
+});
+
+const MEASURE = `(() => {
+ const bar = document.querySelector('.openvibe-navbar');
+ const vis = (el) => el && getComputedStyle(el).display !== 'none' && el.getBoundingClientRect().width > 0;
+ const box = (el) => { const r = el.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right)]; };
+ const name = bar.querySelector('.openvibe-navbar-brand .name');
+ const parts = [['name', name], ['launcher', bar.querySelector('.ovnav-launch')], ['search', bar.querySelector('#openvibe-search-btn')],
+ ['balances', bar.querySelector('.ovnav-chip--all')], ['sign in', bar.querySelector('#openvibe-login-btn')],
+ ['picture', bar.querySelector('#openvibe-avatar-btn')], ['menu', bar.querySelector('#openvibe-burger')]].filter(([, el]) => vis(el)).map(([k, el]) => [k, box(el)]);
+ const minis = [...bar.querySelectorAll('.ovnav-chip--all .ovnav-chip-mini')].filter(vis).length;
+ return { parts, clipped: name.scrollWidth > name.clientWidth + 1, visibleText: name.innerText, minis, width: innerWidth };
+})()`;
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-navfit-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ try {
+ const context = await browser.newContext();
+ for (const width of [320, 360, 393, 412, 430]) {
+ for (const who of ['in', 'out']) {
+ const p = await h.openPage(browser, context, { width });
+ await p.goto(`${base}/${who}`, { maxMs: 5000 });
+ await wait(300);
+ const v = await p.evaluate(MEASURE);
+ const at = `${width}px signed ${who}`;
+ for (let i = 1; i < v.parts.length; i++) {
+ const [a, ra] = v.parts[i - 1]; const [b, rb] = v.parts[i];
+ assert.ok(rb[0] >= ra[1] - 1, `${at}: ${a} ${JSON.stringify(ra)} runs into ${b} ${JSON.stringify(rb)}`);
+ }
+ 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, 1, `${at}: the folded balances show the first one`);
+ assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: a signed-in phone keeps the launcher in the menu`);
+ } else if (width >= 375) {
+ assert.ok(v.parts.some(([k]) => k === 'launcher'), `${at}: signed out, the launcher stays by the name`);
+ }
+ await p.close();
+ }
+ }
+ console.log('navbar-phone-fit-chrome: signed in or out, 320-430px, nothing overlaps; the name reads whole from 360px ok');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -22,11 +22,13 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>
@@ -22,11 +22,13 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>
<body><div id="navbar-mount"></div><main><div id="player" style="position:relative;width:360px;height:202px"></div><div id="list"></div></main>
<body><div id="navbar-mount"></div><main><div id="player" style="position:relative;width:360px;height:202px"></div><div id="list"></div></main>
<script src="/navbar.js"></script><script src="/loading.js"></script>
<script src="/navbar.js"></script><script src="/loading.js"></script>
<script>
<script>
- // search.js asks the real Search; this page answers instead (same shape), slowly for "slow".
+ // search.js asks Search through Network's /api/frame/suggest (every site's connect-src allows openvibe.network); this
+ // page answers instead (same shape), slowly for "slow". Asking search.openvibe.network itself is a failure here.
window.__realFetch = window.fetch;
window.__realFetch = window.fetch;
window.fetch = async (u, init) => {
window.fetch = async (u, init) => {
const url = new URL(String(u), location.href);
const url = new URL(String(u), location.href);
- if (url.hostname === 'search.openvibe.network') return window.__realFetch('/suggest' + url.search, init);
+ if (url.hostname === 'openvibe.network' && url.pathname === '/api/frame/suggest') return window.__realFetch('/suggest' + url.search, init);
+ if (url.hostname === 'search.openvibe.network') throw new TypeError('refused by connect-src');
return window.__realFetch(u, init);
return window.__realFetch(u, init);
};
};
OpenVibeNavbar.init({ service: 'chat', user: null, links: [{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Servers', href: '/servers', icon: 'fa-server' }],
OpenVibeNavbar.init({ service: 'chat', user: null, links: [{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Servers', href: '/servers', icon: 'fa-server' }],