openvibers/openvibe.shared Commits

4.22.0: a tidier navbar (no repeated sites, More for the rest) (#146)

OP OpenVibers committed 16 days ago · 6ce4b7a271 · 10 files, +153 −8

* 4.22.0: a tidier navbar: other sites leave the bar (the launcher has them), more: true links fold into one More, and the bar's rules are in docs/kit.md

* Who OpenVibe is in every site's structured data (description, other names, what it is not, its sites); the browser timing test gives a loaded runner three tries

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a1f335a..f7a7a5d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,28 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 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
++ options… awkward… not front-facing of what the important stuff is"). A sweep of the 38 sites' bars found three
++ problems:
++ - **Other sites repeated** after every site's own links ("Live · Tools · Space · Community"), twice on Codes and
++ News.
++ - The bar no longer repeats them: the launcher lists every site, and the phone drawer keeps "Across OpenVibe".
++ - `networkLinks: <n>` (or `true` for four) brings the strip back for a site that wants it.
++ - **Long bars**, up to twelve entries. Links marked `more: true` now fold into one "More" dropdown, and the drawer
++ lists them like the others. Clicking More opens it; it never follows a link.
++ - **"What shipped" and "Search" as main places.** docs/kit.md ("The navbar's links") sets the rules: four or five
++ places with icons, `more` for the rest, Search as the magnifier, Updates in the footer, the account menu for the
++ person's things.
++- **Who OpenVibe is, in every site's structured data** (`seo.jsonLd.organization()`, the publisher every page names).
++ - The Organization gains a description, `alternateName` (OpenVibe Network, OpenVibers), a
++ `disambiguatingDescription` ("…not the Openvibe mobile social-media aggregator app"), and fifteen sites in `sameAs`.
++ - Why: Google's AI Overview for "whois openvibe" described an unrelated "Openvibe" app (the owner, 2026-10-28).
++ - Network's home page and llms.txt say the same (Network #368).
++- `test/navbar-more.test.js`; `test/seo-org.test.js`; `perf-budget-chrome` gives a loaded runner three tries to report
++ the late block's shift (it failed macOS CI twice on 2026-10-28).
++
## 4.21.0 — 2026-10-28
- **Search on every site** (`search.js`; navbar `search`, `openSearch()`).
ddiff --git a/README.md b/README.md
iindex 069379f..bcbf502 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.21.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.22.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 5b9db6d..8040f98 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.21.0",
++ "version": "4.22.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/docs/kit.md b/docs/kit.md
iindex 4b86b9e..acf82a2 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -196,6 +196,36 @@ kit.badge(text, 'accent' | 'ok' | 'warn' | 'bad' | 'live') · kit.dot(n) · kit.
`search`, `gear`, `members`, `invite`, `chat`, `bell`, `home`, `spark`, `live`, `video`, `image`, `tag`, `coin`,
`trophy`, `map`, `code`, `robot` and `game`.
++## The navbar's links
++
++The 2026-10-28 sweep of all 38 sites' bars found three habits that made them feel crowded and repetitive:
++- other sites repeated after every site's own links ("Live · Tools · Space · Community", twice on some);
++- "What shipped" and "Search" as main places;
++- up to twelve entries.
++
++The rules since 4.22.0:
++
++1. **Four or five main places, with icons**, in the order people use them: the site's home or feed, finding (browse,
++ categories), what is theirs (watches, tickets, saved), and the site's main verb (post, go live, write). The same
++ 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.
++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.
++ The bar no longer repeats them (`networkLinks` brings the strip back for a site that really wants it).
++
++```js
++OpenVibeNavbar.init({ service: 'food', links: [
++ { label: 'Food near you', href: '/near', icon: 'fa-location-dot' },
++ { label: 'Food boxes', href: '/boxes', icon: 'fa-utensils' },
++ { label: 'Meal plan', href: '/plan', icon: 'fa-list-check' },
++ { label: 'Cupboard', href: '/pantry', icon: 'fa-house' },
++ { label: 'Food list', href: '/foods', more: true },
++], appBar: true, search: { owner: 'food', label: 'OpenVibe.Food' } })
++```
++
## The app bar
```js
ddiff --git a/navbar.js b/navbar.js
iindex 9487c4f..dcb0c40 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -852,6 +852,7 @@
const item = byId.get(a.getAttribute('data-link-id'));
// A parent of a dropdown opens it on touch (there is no hover); a second tap follows the link.
const dd = a.parentElement && a.parentElement.classList.contains('ovnav-dd') ? a.parentElement : null;
++ if (dd && a.getAttribute('data-link-id') === 'ovnav-more') { e.preventDefault(); const o = !dd.classList.contains('open'); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); if (o) { 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 (typeof _config.onNavigate === 'function' && a.target !== '_blank') {
@@@ -946,7 +947,8 @@
if (!frame) return '';
const here = currentHost().toLowerCase();
const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
-- const max = inDrawer ? 8 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : 4);
++ const max = inDrawer ? 8 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : _config.networkLinks === true ? 4 : 0);
++ if (!max) return '';
const pick = frame.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
if (!pick.length) return '';
if (inDrawer) {
@@@ -1645,7 +1647,7 @@
nav.innerHTML = `
${brandHTML(brand)}
<div class="openvibe-navbar-links">
-- ${links.map(l => linkHTML(l, false)).join('')}
++ ${links.filter(l => !l.more).map(l => linkHTML(l, false)).join('')}${links.some(l => l.more && !l.hidden) ? linkHTML({ id: 'ovnav-more', label: 'More', href: '#', children: links.filter(l => l.more) }, false) : ''}
${networkLinksHTML(links)}
${u && u.role === 'admin' && _config.adminLink !== false && !flatLinks(links).some(l => l.id === 'admin') ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} Admin</a>` : ''}
</div>
ddiff --git a/package.json b/package.json
iindex 1f8f921..ca2be6f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.21.0",
++ "version": "4.22.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/seo.js b/seo.js
iindex 30c613c..f111f5d 100644
---- a/seo.js
++++ b/seo.js
@@@ -86,7 +86,18 @@ function headTags(o = {}) {
return out.join('\n');
}
--const ORG = { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png', sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj'] };
++// OpenVibe, said so search engines and AI cannot take it for something else (the owner, 2026-10-28: Google's AI Overview
++// for "whois openvibe" described an unrelated "Openvibe" mobile app): what it is, its other names, what it is not, and its
++// sites. Network's home page carries the same Organization (#org); every site's pages name it as their publisher.
++const ORG = {
++ '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', alternateName: ['OpenVibe Network', 'OpenVibers'],
++ url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png',
++ description: 'OpenVibe is an open-source, community-run network of websites that share one free account: live streaming from the browser (OpenVibe.Live), more than 180 online tools (OpenVibe.Tools), chat and the Vibe assistant (OpenVibe.Chat), games, a wiki, news, reviews, deals and an open developer platform.',
++ disambiguatingDescription: 'The open-source network of websites at openvibe.network, made by OpenVibers; not the Openvibe mobile social-media aggregator app.',
++ sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj', 'https://openvibe.live', 'https://openvibe.tools', 'https://openvibe.chat', 'https://openvibe.games',
++ 'https://openvibe.media', 'https://openvibe.video', 'https://openvibe.community', 'https://openvibe.space', 'https://openvibe.wiki', 'https://openvibe.blog',
++ 'https://openvibe.news', 'https://openvibe.services', 'https://openre.stream'],
++};
const jsonLd = {
organization(extra = {}) { return { '@context': 'https://schema.org', ...ORG, ...extra }; },
ddiff --git a/test/navbar-more.test.js b/test/navbar-more.test.js
nnew file mode 100644
iindex 0000000..21e2dc9
---- /dev/null
++++ b/test/navbar-more.test.js
@@@ -0,0 +1,63 @@
++'use strict';
++// navbar.js 4.22.0 — a tidier bar (the owner, 2026-10-28: "reanalyze all the nav bars… redundant feeling options…
++// not front-facing of what the important stuff is"): other sites are no longer repeated after this site's links (the
++// launcher lists them all), and links marked more: true fold into one "More" dropdown (the phone drawer lists them).
++const assert = require('assert');
++const path = require('path');
++const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');
++
++function stubElement(tag) {
++ const el = {
++ tag, style: { setProperty() {} }, textContent: '', className: '', id: '', innerHTML: '', dataset: {}, attrs: {}, children: [],
++ setAttribute(k, v) { this.attrs[k] = String(v); }, getAttribute(k) { return this.attrs[k] ?? null; },
++ appendChild(c) { this.children.push(c); return c; }, prepend(c) { this.children.unshift(c); }, remove() {}, addEventListener() {},
++ querySelector() { return null; }, querySelectorAll() { return []; },
++ };
++ el.classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
++ return el;
++}
++function load() {
++ global.location = { hostname: 'openvibe.food', href: 'https://openvibe.food/', pathname: '/', protocol: 'https:', origin: 'https://openvibe.food' };
++ global.window = { location: global.location };
++ try { Object.defineProperty(global, 'navigator', { value: { userAgent: 'test' }, configurable: true }); } catch { /* */ }
++ const body = stubElement('body');
++ const created = [];
++ global.document = { cookie: '', head: stubElement('head'), body, documentElement: stubElement('html'), title: 'T', getElementById: () => null,
++ createElement: (tag) => { const e = stubElement(tag); created.push(e); return e; }, addEventListener() {}, removeEventListener() {}, dispatchEvent() {} };
++ global.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i && i.detail; } };
++ const store = {};
++ global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } };
++ global.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} };
++ // The Frame's site list (what the launcher and the old strip read), as Network's /api/chrome would give it.
++ global.fetch = async () => ({ ok: true, status: 200, json: async () => ({ nav: [{ id: 'live', name: 'Live', url: 'https://openvibe.live/', icon: 'live' }, { id: 'tools', name: 'Tools', url: 'https://openvibe.tools/', icon: 'tools' }], groups: [] }) });
++ delete globalThis.OVFrame;
++ delete require.cache[require.resolve(NAVBAR_PATH)];
++ return { navbar: require(NAVBAR_PATH), created };
++}
++const barHtml = (created) => (created.filter((e) => e.tag === 'nav' && /openvibe-navbar-links/.test(e.innerHTML)).pop() || { innerHTML: '' }).innerHTML;
++const between = (html, a, b) => { const i = html.indexOf(a); const j = html.indexOf(b, i); return i < 0 ? '' : html.slice(i, j < 0 ? undefined : j); };
++
++{
++ const { navbar, created } = load();
++ navbar.init({ service: 'food', user: null, links: [
++ { label: 'Food near you', href: '/near', icon: 'fa-location-dot' },
++ { label: 'Food boxes', href: '/boxes', icon: 'fa-utensils' },
++ { label: 'Food list', href: '/foods', more: true },
++ { label: 'What shipped', href: '/updates', more: true },
++ ] });
++ const bar = between(barHtml(created), 'openvibe-navbar-links', 'openvibe-navbar-spacer');
++ assert.ok(bar.includes('>Food near you<') && bar.includes('>Food boxes<'), 'the main places are on the bar');
++ assert.ok(/data-link-id="ovnav-more"[^>]*>(<[^>]+>)*More/.test(bar) || bar.includes('data-link-id="ovnav-more"'), 'one More');
++ const more = between(bar, 'ovnav-dd-menu', '</div>');
++ assert.ok(more.includes('>Food list<') && more.includes('>What shipped<'), 'secondary links are inside More');
++ assert.ok(!/class="ovnav-net"/.test(bar), 'no other sites repeated on the bar');
++ const drawer = between(barHtml(created), 'id="openvibe-drawer"', 'ovnav-drawer-net');
++ assert.ok(drawer.includes('>Food list<') && drawer.includes('>What shipped<'), 'the drawer lists every link');
++ assert.ok(!drawer.includes('ovnav-more'), 'and no More in the drawer');
++}
++{
++ const { navbar, created } = load();
++ navbar.init({ service: 'food', user: null, links: [{ label: 'Near', href: '/near' }] });
++ assert.ok(!barHtml(created).includes('data-link-id="ovnav-more"'), 'no secondary links: no More');
++}
++console.log('navbar more: secondary links fold into More, other sites leave the bar ok');
ddiff --git a/test/perf-budget-chrome.test.js b/test/perf-budget-chrome.test.js
iindex e8d06e4..ecc5c72 100644
---- a/test/perf-budget-chrome.test.js
++++ b/test/perf-budget-chrome.test.js
@@@ -31,7 +31,9 @@ document.addEventListener('click', () => { const t = performance.now(); while (p
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-perf-budget-test-'));
try {
// launch() options: this call starts Chrome and stops it again.
-- const m = await measure({ base, browser: { tmpDir } });
++ // A loaded CI runner (macOS) sometimes reports the late block's shift a frame too late: up to three tries.
++ const shifted = async (run) => { let r; for (let i = 0; i < 3; i++) { r = await run(); if (r.cwv.cls > 0 && r.cwv.lcpMs > 0) break; } return r; };
++ const m = await shifted(() => measure({ base, browser: { tmpDir } }));
console.log('perf-budget-chrome: cwv', JSON.stringify(m.cwv));
assert.ok(m.cwv.cls > 0, `the late block shifts the text: ${JSON.stringify(m.cwv)}`);
assert.ok(m.cwv.lcpMs > 0, 'the text paints');
@@@ -42,7 +44,7 @@ document.addEventListener('click', () => { const t = performance.now(); while (p
// A running browser is reused and left running.
const running = await h.launch({ tmpDir });
try {
-- const again = await measure({ base, browser: { running } });
++ const again = await shifted(() => measure({ base, browser: { running } }));
assert.ok(again.cwv.cls > 0 && again.cwv.lcpMs > 0, JSON.stringify(again.cwv));
assert.strictEqual(running.closed, false, 'a browser the caller started stays open');
} finally { await running.close(); }
ddiff --git a/test/seo-org.test.js b/test/seo-org.test.js
nnew file mode 100644
iindex 0000000..793cd6f
---- /dev/null
++++ b/test/seo-org.test.js
@@@ -0,0 +1,15 @@
++'use strict';
++// seo.jsonLd.organization(): who OpenVibe is, in every site's structured data (4.22.0), so search engines and AI cannot
++// take it for something else: the one @id, its other names, what it is, what it is not, and its sites.
++const assert = require('assert');
++const seo = require('../seo');
++
++const org = seo.jsonLd.organization();
++assert.strictEqual(org['@id'], 'https://openvibe.network/#org');
++assert.strictEqual(org.name, 'OpenVibe');
++assert.deepStrictEqual(org.alternateName, ['OpenVibe Network', 'OpenVibers']);
++assert.ok(/open-source, community-run network of websites/.test(org.description));
++assert.ok(/not the Openvibe mobile social-media aggregator app/.test(org.disambiguatingDescription));
++['https://github.com/OpenVibers', 'https://openvibe.live', 'https://openvibe.tools', 'https://openvibe.chat', 'https://openre.stream'].forEach((u) => assert.ok(org.sameAs.includes(u), u));
++assert.strictEqual(seo.jsonLd.website({ name: 'X', url: 'https://x.example/', description: 'd' }).publisher['@id'], org['@id'], 'a WebSite names it by @id');
++console.log('seo org: who OpenVibe is ok');