openvibers/openvibe.shared Commits

4.22.1: search suggestions on every site (through Network) and a navbar that fits a phone signed in (#147)

OP OpenVibers committed 16 days ago · e646a56d9e · 9 files, +156 −17

* 4.22.1: search suggestions through Network's /api/frame/suggest (33 sites' connect-src refused search.openvibe.network)

* 4.22.1: the navbar fits a phone signed in (the name gives way, one balance, the launcher in the menu, tighter buttons; a Chrome check 320-430px)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f7a7a5d..efb7123 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex bcbf502..f819d9e 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 8040f98..21e9e83 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/docs/kit.md b/docs/kit.md
iindex acf82a2..49aa199 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -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 first
What 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 anythin
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 })`.
## Loading
ddiff --git a/navbar.js b/navbar.js
iindex dcb0c40..444d882 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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; }
ddiff --git a/package.json b/package.json
iindex ca2be6f..42fb4a5 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/search.js b/search.js
iindex d00de02..d0b0dc3 100644
---- a/search.js
++++ b/search.js
@@@ -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' });
ddiff --git a/test/navbar-phone-fit-chrome.test.js b/test/navbar-phone-fit-chrome.test.js
nnew file mode 100644
iindex 0000000..8cb2dca
---- /dev/null
++++ b/test/navbar-phone-fit-chrome.test.js
@@@ -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); });
ddiff --git a/test/search-loading-chrome.test.js b/test/search-loading-chrome.test.js
iindex 60c8d70..5623c7b 100644
---- a/test/search-loading-chrome.test.js
++++ b/test/search-loading-chrome.test.js
@@@ -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' }],