openvibers/openvibe.shared Commits
4.117.0: a navbar link can be the site's one featured piece (#302)
Changes
@@@ -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@@@ -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 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.@@@ -773,8 +773,10 @@ Brand: derived from the hostname — `pastes.openvibe.tools` renders aslegacy brandName/brandIcon. compact: 'auto' (default — the brand shortensto 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.@@@ -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"}@@@ -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);@@@ -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": {nnew file mode 100644@@@ -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');
@@ -1,5 +1,12 @@
@@ -1,5 +1,12 @@
# Changelog
# 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
## 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
- **A transfer of a people-locked item carries the recipient's slot.** The sheet wraps the content key for the new
@@ -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.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
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.
@@ -773,8 +773,10 @@ Brand: derived from the hostname — `pastes.openvibe.tools` renders as
@@ -773,8 +773,10 @@ Brand: derived from the hostname — `pastes.openvibe.tools` renders as
legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
to the subdomain on narrow viewports), 'always', 'never'.
to the subdomain on narrow viewports), 'always', 'never'.
Menus are modular: every site keeps the shared account menu and adds its own
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()
({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
/ setLinks() do the same at runtime. Signed-in users also get a
/ 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.
"Recently used" row fed by the shared history module when it is loaded.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.116.1",
+ "version": "4.117.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -28,7 +28,8 @@
@@ -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 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",
"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)",
"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"
"updated": "2026-11-06"
}
}
@@ -112,6 +112,9 @@
@@ -112,6 +112,9 @@
.ovnav-drawer.open { display: flex; animation: openvibe-slide-down .2s ease; }
.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 { 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); }
.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)); }
.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)); } }
@media (hover: hover) { .ovnav-drawer a:hover { background: var(--accent-glow, rgba(59,130,246,.14)); } }
.ovnav-drawer a[hidden] { display: none; }
.ovnav-drawer a[hidden] { display: none; }
@@ -241,6 +244,19 @@
@@ -241,6 +244,19 @@
}
}
.openvibe-navbar-links a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
.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); }
.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; }
.openvibe-navbar-spacer { flex: 1; }
@@ -1231,18 +1247,25 @@
@@ -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) {
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 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 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 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;
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('');
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>`;
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 : []), []);
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() {
function currentLinks() {
const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
const path = (typeof location !== 'undefined' && location.pathname) || '/';
const path = (typeof location !== 'undefined' && location.pathname) || '/';
@@ -1838,7 +1861,7 @@
@@ -1838,7 +1861,7 @@
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
${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>' : ''}
${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>
</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));
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
bindLinks(nav, links);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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');