openvibers/openvibe.shared Commits

4.117.0: a navbar link can be the site's one featured piece (#302)

OP OpenVibers committed 7 days ago · ce969f6bdf · 6 files, +116 −9

* 4.117.0: a navbar link can be the site's one featured piece

* 4.117.0: the release files name the version

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4a6444d..9592f60 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,12 @@
# Changelog
++## 4.117.0 — 2026-11-06
++
++- **A navbar link can be the site's one featured piece** (`navbar.js`; the owner, 2026-11-06: the Node on the
++ Network is "a central piece", on its own subdomain). A link with `featured: true` draws as a quiet accent pill
++ with its glyph on the bar — one hairline, one tint, the glyph, and nothing that moves — and leads the phone's
++ menu. Every other link keeps its place and its plain look.
++
## 4.116.1 — 2026-11-06
- **A transfer of a people-locked item carries the recipient's slot.** The sheet wraps the content key for the new
ddiff --git a/README.md b/README.md
iindex e14deb0..4da37c3 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.116.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.117.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.
@@@ -773,8 +773,10 @@ Brand: derived from the hostname — `pastes.openvibe.tools` renders as
legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
to the subdomain on narrow viewports), 'always', 'never'.
Menus are modular: every site keeps the shared account menu and adds its own
-- pieces — links: [{label, href, icon?, active?}] replaces the service's top
-- links; menu: { before: [item], after: [item] } adds dropdown rows
++ pieces — links: [{label, href, icon?, active?, featured?}] replaces the service's
++ top links; featured: true draws that one link as a quiet accent pill with its
++ glyph, and puts it first in the phone's menu (4.117.0); menu: { before: [item],
++ after: [item] } adds dropdown rows
({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
/ setLinks() do the same at runtime. Signed-in users also get a
"Recently used" row fed by the shared history module when it is loaded.
ddiff --git a/STATUS.json b/STATUS.json
iindex 0e6a645..d0217ab 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.116.1",
++ "version": "4.117.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)",
@@@ -28,7 +28,8 @@
"the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went",
"the Dock accepts a refreshing token (string, sync or async) and an optional read-only scope set: the extension hides approve/decline and the chat composer while keeping reads",
"vibes-pay.js: OpenVibe.vibes.pay(payUrl) opens the Vibes Pay sheet (popup on a computer, full page on a phone), resolves { status } from an origin-checked postMessage and binds <button data-ov-vibes-pay> in the Vibes accent, both themes (D12)",
-- "openvibe-shared/config accepts x-unit seconds for an integer owner setting, so an owner form can label the input in seconds"
++ "openvibe-shared/config accepts x-unit seconds for an integer owner setting, so an owner form can label the input in seconds",
++ "a navbar link can be the site's one featured piece (featured: true): a quiet accent pill with its glyph, first in the phone's menu (4.117.0)"
],
"updated": "2026-11-06"
}
ddiff --git a/navbar.js b/navbar.js
iindex 8f2d47f..d7dfd4a 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -112,6 +112,9 @@
.ovnav-drawer.open { display: flex; animation: openvibe-slide-down .2s ease; }
.ovnav-drawer a { display: flex; align-items: center; gap: 10px; padding: 11px 12px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14.5px; font-weight: 600; }
.ovnav-drawer a.ovnav-sublink { padding-left: 34px; font-weight: 500; color: var(--text-secondary, #a8b3c4); }
++ /* A featured link in the phone's menu: the same one accent, as a quiet line rather than a pill. */
++ .ovnav-drawer a.ovnav-link--featured { color: var(--accent-light, #60a5fa); font-weight: 650; }
++ .ovnav-drawer a.ovnav-link--featured .icon { color: var(--accent-light, #60a5fa); }
.ovnav-drawer a.active { background: var(--accent-glow, rgba(59,130,246,.14)); }
@media (hover: hover) { .ovnav-drawer a:hover { background: var(--accent-glow, rgba(59,130,246,.14)); } }
.ovnav-drawer a[hidden] { display: none; }
@@@ -241,6 +244,19 @@
}
.openvibe-navbar-links a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
.openvibe-navbar-links a.active { background: var(--bg-tertiary, #2a2a38); color: var(--accent-light, #60a5fa); }
++ /* A featured link (4.117.0): one quiet accent pill with its glyph, for the piece a site is
++ built around (the Node on the Network). Deliberate, not loud: one hairline, one tint, the
++ glyph, and nothing that moves. */
++ .openvibe-navbar-links a.ovnav-link--featured {
++ display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px 5px 10px;
++ border-radius: 999px; font-weight: 650; color: var(--accent-light, #60a5fa);
++ border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent);
++ background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent);
++ transition: none;
++ }
++ .openvibe-navbar-links a.ovnav-link--featured:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); color: var(--accent-light, #60a5fa); }
++ .openvibe-navbar-links a.ovnav-link--featured .icon { opacity: 1; margin-right: 0; color: var(--accent-light, #60a5fa); }
++ .openvibe-navbar-links a.ovnav-link--featured .ovnav-ic { width: 15px; height: 15px; }
.openvibe-navbar-spacer { flex: 1; }
@@@ -1231,18 +1247,25 @@
}
}
-- /** One top-level link. Supports { label, href, icon, id, elId, page, hidden, external, dot, dotId, children: [...] }. */
++ /** One top-level link. Supports { label, href, icon, id, elId, page, hidden, external, dot, dotId,
++ featured, children: [...] }. featured: true draws it as the site's one accent pill (4.117.0). */
function linkHTML(l, inDrawer) {
const attrs = `${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.elId ? ` id="${escapeAttr((inDrawer ? 'drawer-' : '') + l.elId)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}${l.hidden ? ' hidden' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}`;
const kids = Array.isArray(l.children) ? l.children.filter(Boolean) : [];
const inner = `${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(l.label)}</span>${l.dot !== undefined ? `<span class="ovnav-dot"${l.dotId && !inDrawer ? ` id="${escapeAttr(l.dotId)}"` : ''}${l.dot ? '' : ' hidden'}></span>` : ''}`;
-- const a = `<a href="${escapeAttr(l.href || '#')}" class="ovnav-link${l.active ? ' active' : ''}"${attrs}>${inner}${kids.length && !inDrawer ? '<span class="ovnav-caret" aria-hidden="true">▾</span>' : ''}</a>`;
++ const a = `<a href="${escapeAttr(l.href || '#')}" class="ovnav-link${l.active ? ' active' : ''}${l.featured ? ' ovnav-link--featured' : ''}"${attrs}>${inner}${kids.length && !inDrawer ? '<span class="ovnav-caret" aria-hidden="true">▾</span>' : ''}</a>`;
if (!kids.length) return a;
const menu = kids.map(k => `<a href="${escapeAttr(k.href || '#')}" class="ovnav-link ovnav-sublink"${k.id ? ` data-link-id="${escapeAttr(k.id)}"` : ''}${k.hidden ? ' hidden' : ''}>${k.icon ? `<span class="icon">${navIcon(k.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(k.label)}</span></a>`).join('');
return inDrawer ? a + menu : `<div class="ovnav-dd"${l.hidden ? ' hidden' : ''}>${a}<div class="ovnav-dd-menu">${menu}</div></div>`;
}
const flatLinks = (list) => list.reduce((out, l) => out.concat([l], Array.isArray(l.children) ? l.children : []), []);
++ /** The drawer's order: a featured link leads the menu (the phone's first entry), the rest keep theirs. */
++ function drawerOrder(list) {
++ const items = (list || []).filter(Boolean);
++ return items.filter(l => l.featured && !l.hidden).concat(items.filter(l => !(l.featured && !l.hidden)));
++ }
++
function currentLinks() {
const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
const path = (typeof location !== 'undefined' && location.pathname) || '/';
@@@ -1838,7 +1861,7 @@
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
${links.length ? '<button type="button" class="ovnav-burger" id="openvibe-burger" aria-label="Menu" aria-haspopup="true" aria-expanded="false"><span></span><span></span><span></span></button>' : ''}
</div>
-- ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${links.map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div>${u ? '' : '<div class="ovnav-display" hidden></div>'}</div>` : ''}
++ ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${drawerOrder(links).map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div>${u ? '' : '<div class="ovnav-display" hidden></div>'}</div>` : ''}
`;
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
ddiff --git a/package.json b/package.json
iindex f2a721d..89b0595 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.116.1",
++ "version": "4.117.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/navbar-featured.test.js b/test/navbar-featured.test.js
nnew file mode 100644
iindex 0000000..b9d955f
---- /dev/null
++++ b/test/navbar-featured.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++// navbar.js 4.117.0 — a featured link: the piece a site is built around (the Node on the Network) draws as one
++// quiet accent pill with its glyph on the bar, and leads the phone's menu. Deliberate, not loud: no animation.
++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.network', href: 'https://openvibe.network/', pathname: '/', protocol: 'https:', origin: 'https://openvibe.network' };
++ 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() {} };
++ global.fetch = async () => ({ ok: true, status: 200, json: async () => ({ nav: [], 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); };
++
++{
++ // The Node is the piece the Network is built around: it leads, and it is a pill with its glyph.
++ const { navbar, created } = load();
++ navbar.init({ service: 'network', user: null, links: [
++ { label: 'Node', href: 'https://node.openvibe.network', icon: 'fa-house', featured: true },
++ { label: 'My Account', href: '/my' },
++ { label: 'Themes', href: '/themes' },
++ ] });
++ const bar = between(barHtml(created), 'openvibe-navbar-links', 'openvibe-navbar-spacer');
++ assert.ok(bar.includes('ovnav-link--featured'), 'the featured link is drawn as the pill');
++ assert.ok(/href="https:\/\/node\.openvibe\.network"[^>]*class="ovnav-link ovnav-link--featured"/.test(bar), 'the pill links to the Node');
++ assert.ok(bar.includes('fa-house') || bar.includes('ovnav-ic'), 'the pill carries its glyph');
++ // Deliberate, not loud: one accent, no animation on the pill.
++ assert.ok(!/ovnav-link--featured[^}]*animation/.test(bar), 'the pill does not animate');
++ const drawer = between(barHtml(created), 'id="openvibe-drawer"', 'ovnav-drawer-net');
++ assert.ok(drawer.indexOf('>Node<') >= 0 && drawer.indexOf('>Node<') < drawer.indexOf('>My Account<'), 'on a phone the Node is the first entry of the menu');
++}
++{
++ // Without the option, nothing changes: an ordinary link, in the order it was given.
++ const { navbar, created } = load();
++ navbar.init({ service: 'network', user: null, links: [
++ { label: 'My Account', href: '/my' },
++ { label: 'Node', href: 'https://node.openvibe.network', icon: 'fa-house' },
++ ] });
++ const bar = between(barHtml(created), 'openvibe-navbar-links', 'openvibe-navbar-spacer');
++ assert.ok(!bar.includes('ovnav-link--featured'), 'a plain link is not a pill');
++ const drawer = between(barHtml(created), 'id="openvibe-drawer"', 'ovnav-drawer-net');
++ assert.ok(drawer.indexOf('>My Account<') < drawer.indexOf('>Node<'), 'and it keeps its place in the menu');
++}
++{
++ // The stylesheet carries the pill: one hairline, one tint, the glyph, and nothing that moves.
++ const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');
++ assert.ok(src.includes('.openvibe-navbar-links a.ovnav-link--featured'), 'the pill has its own rule');
++ assert.ok(/ovnav-link--featured \{[^}]*border-radius: 999px/.test(src), 'the pill is round');
++ assert.ok(/ovnav-link--featured \{[^}]*transition: none/.test(src), 'and it does not animate');
++}
++console.log('navbar featured: one quiet accent pill, first in the phone menu ok');