openvibers/openvibe.shared Commits
Line or solid on purpose; an app bar that slides; placeholders; the mark while pages load (4.70.0) (#222)
Changes
@@@ -4,7 +4,32 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).--## 4.69.0++## 4.70.0 — 2026-11-01++++- **Line or solid, on purpose** (plan L49; the owner, 2026-11-01: "both sets for any size… swap between them…++ whichever fits the area"). `.ovg-solid` and `.ovg-line` force one at any size. An active control (the current tab,++ a pressed or selected button, `aria-current`, `aria-pressed`, `aria-selected`, `.is-here`) wears the solid drawing,++ as an app's tab bar fills the tab you are on. Kept low in specificity (`:where`) so a site can override it.++- **The app bar feels like an app's** (plan L52; "improve the mobile button so it feels seamless and smooth"):++ - it is drawn once and kept between pages;++ - one pill slides to the tab you are on;++ - a tap lights its tab at once, before the page arrives;++ - a press gives, and the new tab's icon pops.++- **Placeholders shaped like the content** ("placeholder elements while it loads, a fake thumbnail, so it is not++ jarring"):++ - `kit.skeleton({ kind, count })` with seven shapes: card (16:9), row, tile, chip, line, chat and stat;++ - kit.css shimmer, never a spinner, still under reduced motion and Calm;++ - announced as busy.++- **Moving between pages** ("the logo changing while it loads… smarter loading"):++ - `OpenVibeNavbar.busy(on)`: the mark works while something loads;++ - a plain same-site link starts the mark at once, and the next page's mark takes over (a download gives up after++ 10 s);++ - speculation rules prefetch same-site pages on hover or press (`moderate`), never /auth, /api, sign-out or a++ download (`speculate: false` turns it off);++ - cross-document view transitions keep the navbar and app bar still while a server-rendered page cross-fades (off++ under reduced motion).++++## 4.69.0 — 2026-11-01- **Icons that read at every size** (plan L49; the owner, 2026-11-01: "really tiny icons… a solid colour rather thanthe border lines… so all our icons look nice across different sizes and devices"). 48 glyphs most often shown small@@@ -17,7 +42,7 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).(sheets, modals, full-screen chats), `.ov-scroll-x` scrolls sideways only. On a phone the kit's sidebar sectionsno longer trap the page.--## 4.68.0++## 4.68.0 — 2026-11-01- **83 more glyphs, 446 Font Awesome names** (plan L49 step 1; the owner, 2026-11-01: "why Font Awesome? We should beusing OpenVibe Design's icon system for all our icons"). Every non-brand name Live uses now has a glyph: trend up and@@@ -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.69.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.70.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.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.69.0",++ "version": "4.70.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)",@@@ -17,6 +17,7 @@(function (root) {'use strict';let barEl = null;++ let resizeBound = false;function spec(ctx) {const cfg = ctx.config.appBar;@@@ -49,7 +50,19 @@.ovnav-appbar .ovnav-ab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -4px; border-radius: 12px; }.ovnav-appbar .ovnav-ab.is-here { color: var(--text-primary, #e8ecf3); }.ovnav-appbar .ovnav-ab-ic { position: relative; display: grid; place-items: center; width: 52px; height: 28px; border-radius: 999px; font-size: 18px; transition: background .15s; }-- .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic { background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic { color: var(--accent-light, var(--accent, #60a5fa)); }++ /* 4.70.0 (the owner, 2026-11-01: "improve the mobile button so it feels seamless and smooth"): one pill slides to the++ tab you are on (the bar is kept between pages, so it can), a tap lights its tab at once, a press gives and the new++ tab's icon pops. */++ .ovnav-appbar .ovnav-ab-pill { position: absolute; left: 0; top: 0; width: 52px; height: 28px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); transform: translate(var(--ab-x, 0px), var(--ab-y, 6px)); transition: transform .42s cubic-bezier(.2,1.25,.3,1), opacity .2s; pointer-events: none; }++ .ovnav-appbar.ab-still .ovnav-ab-pill { transition: none; }++ .ovnav-appbar:not(:has(.is-here)) .ovnav-ab-pill { opacity: 0; }++ .ovnav-appbar .ovnav-ab { -webkit-tap-highlight-color: transparent; }++ .ovnav-appbar .ovnav-ab-ic, .ovnav-appbar .ovnav-ab-l { position: relative; transition: transform .2s cubic-bezier(.2,1.4,.3,1), color .2s; }++ .ovnav-appbar .ovnav-ab:active .ovnav-ab-ic { transform: scale(.88); }++ .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic svg { animation: ovAbPop .45s cubic-bezier(.2,1.5,.3,1); }++ .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-l { font-weight: 700; }++ @keyframes ovAbPop { 0% { transform: scale(.7); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } }.ovnav-appbar .ovnav-ab-ic svg { width: 1em; height: 1em; }.ovnav-appbar .ovnav-ab-l { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovnav-appbar .ovnav-ab-dot { position: absolute; top: 2px; right: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 2px var(--bg-secondary, #121826); }@@@ -57,24 +70,50 @@html.ov-has-appbar:has(:is(input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select, [contenteditable="true"]):focus) .ovnav-appbar,body.ov-panel-open .ovnav-appbar { transform: translateY(110%); }}-- @media (prefers-reduced-motion: reduce) { .ovnav-appbar, .ovnav-appbar .ovnav-ab-ic { transition: none; } }++ @media (prefers-reduced-motion: reduce) { .ovnav-appbar, .ovnav-appbar .ovnav-ab-ic, .ovnav-appbar .ovnav-ab-pill { transition: none; } .ovnav-appbar .ovnav-ab-ic svg { animation: none !important; } }@media print { .ovnav-appbar { display: none !important; } }`;document.head.appendChild(st);}++ /** Slide the pill under the lit tab's icon (the bar is its offset parent). */++ function placePill(bar, instant) {++ const pill = bar && bar.querySelector('.ovnav-ab-pill');++ const ic = bar && bar.querySelector('.ovnav-ab.is-here .ovnav-ab-ic');++ if (!pill || !ic) return;++ if (instant) bar.classList.add('ab-still');++ bar.style.setProperty('--ab-x', `${ic.offsetLeft + (ic.offsetWidth - 52) / 2}px`);++ bar.style.setProperty('--ab-y', `${ic.offsetTop}px`);++ const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16);++ if (instant) raf(() => raf(() => bar.classList.remove('ab-still')));++ }++ /** Light one tab (and only it), as the page it opens. */++ function light(bar, a) {++ bar.querySelectorAll('.ovnav-ab').forEach((t) => { const on = t === a; t.classList.toggle('is-here', on); if (on) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current'); });++ placePill(bar);++ }++ const keyOf = (s) => s.items.map((l) => `${l.href}|${l.label}|${l.icon || ''}|${l.dot ? 1 : 0}`).join(',') + (s.more ? '+more' : '');++/** Draw (or take away) the bar for the navbar's current state: { config, links, activePage, nav, navIcon, escapeAttr, escapeHtml, flatLinks }. */function render(ctx) {if (typeof document === 'undefined' || !document.body) return;-- if (barEl) { barEl.remove(); barEl = null; }const s = spec(ctx);const de = document.documentElement;++ // The same tabs as before: keep the bar (no flash, no jump) and move only what is lit, so the pill slides.++ if (barEl && barEl.isConnected && s && s.items.length >= 2 && barEl.getAttribute('data-ab-key') === keyOf(s)) {++ const tabs = barEl.querySelectorAll('a.ovnav-ab');++ const lit = s.items.findIndex((l) => l.active);++ light(barEl, lit >= 0 ? tabs[lit] : null);++ return;++ }++ if (barEl) { barEl.remove(); barEl = null; }if (!s || s.items.length < 2) { if (de && de.classList) de.classList.remove('ov-has-appbar'); return; }styles();const { navIcon, escapeAttr, escapeHtml } = ctx;const bar = document.createElement('nav');bar.className = 'ovnav-appbar';bar.setAttribute('aria-label', 'App');-- bar.innerHTML = s.items.map((l) => `<a href="${escapeAttr(l.href)}" class="ovnav-ab${l.active ? ' is-here' : ''}"${l.active ? ' aria-current="page"' : ''}${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}><span class="ovnav-ab-ic">${navIcon(l.icon || 'fa-circle')}${l.dot ? '<span class="ovnav-ab-dot" aria-label="new"></span>' : ''}</span><span class="ovnav-ab-l">${escapeHtml(l.label)}</span></a>`).join('')++ bar.setAttribute('data-ab-key', keyOf(s));++ bar.innerHTML = '<span class="ovnav-ab-pill" aria-hidden="true"></span>' + s.items.map((l) => `<a href="${escapeAttr(l.href)}" class="ovnav-ab${l.active ? ' is-here' : ''}"${l.active ? ' aria-current="page"' : ''}${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}><span class="ovnav-ab-ic">${navIcon(l.icon || 'fa-circle')}${l.dot ? '<span class="ovnav-ab-dot" aria-label="new"></span>' : ''}</span><span class="ovnav-ab-l">${escapeHtml(l.label)}</span></a>`).join('')+ (s.more ? `<button type="button" class="ovnav-ab" data-ab-more aria-haspopup="true"><span class="ovnav-ab-ic">${navIcon('fa-bars')}</span><span class="ovnav-ab-l">More</span></button>` : '');bar.addEventListener('click', (e) => {if (e.target.closest && e.target.closest('[data-ab-more]')) {@@@ -84,6 +123,7 @@}const a = e.target.closest && e.target.closest('a.ovnav-ab');if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;++ light(bar, a);const id = a.getAttribute('data-link-id');const item = id ? ctx.flatLinks(ctx.links).concat(Array.isArray(ctx.config.appBar) ? ctx.config.appBar : []).find((l) => l && l.id === id) : null;if (item && typeof item.onClick === 'function' && item.onClick(e) === false) e.preventDefault();@@@ -96,6 +136,8 @@document.body.appendChild(bar);barEl = bar;if (de && de.classList) de.classList.add('ov-has-appbar');++ placePill(bar, true);++ if (!resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') { resizeBound = true; window.addEventListener('resize', () => placePill(barEl, true)); }}const api = { render, spec };@@@ -332,3 +332,37 @@ div.ovk-set-row { cursor: default; }@media (max-width: 900px), (hover: none) {.ovk-sidebar-secs { overscroll-behavior-y: auto; }}++++/* ── Placeholders shaped like the content (4.70.0, kit.skeleton; plan L52) ── A soft light passes over grey shapes the size of++ what is coming; nothing spins; still under reduced motion and Calm. A list paints these in its first HTML and fills them in++ place. */++.ovk-skel { display: block; border-radius: 8px; background: color-mix(in srgb, var(--text-primary, #e8edf3) 7%, transparent); background-image: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--text-primary, #e8edf3) 9%, transparent) 50%, transparent 70%); background-size: 220% 100%; background-repeat: no-repeat; animation: ovkShimmer 1.6s ease-in-out infinite; }++@keyframes ovkShimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }++.ovk-skels { display: grid; gap: 12px; }++.ovk-skels-card { grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }++.ovk-skels-tile { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }++.ovk-skels-chip { display: flex; flex-wrap: wrap; gap: 8px; }++.ovk-skel-card { display: grid; gap: 8px; }++.ovk-skel-thumb { aspect-ratio: 16 / 9; border-radius: 12px; }++.ovk-skel-l1 { height: 13px; width: 86%; }++.ovk-skel-l2 { height: 11px; width: 58%; }++.ovk-skel-row { display: flex; align-items: center; gap: 12px; }++.ovk-skel-avatar { flex: none; width: 40px; height: 40px; border-radius: 50%; }++.ovk-skel-lines { flex: 1; display: grid; gap: 7px; min-width: 0; }++.ovk-skel-tile { aspect-ratio: 1; border-radius: 14px; }++.ovk-skel-chip { width: 84px; height: 30px; border-radius: 999px; }++.ovk-skel-line { height: 12px; }++.ovk-skel-chat { display: flex; align-items: center; gap: 8px; }++.ovk-skel-dot { flex: none; width: 22px; height: 22px; border-radius: 50%; }++.ovk-skel-stat { display: grid; gap: 6px; padding: 12px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.08)); }++.ovk-skel-num { height: 22px; width: 48%; }++@media (prefers-reduced-motion: reduce) { .ovk-skel { animation: none; } }++html[data-ov-motion=reduced] .ovk-skel { animation: none; }++++/* ── Moving between pages (4.70.0, plan L52) ── A server-rendered site's pages cross-fade while the navbar and the app bar++ stay put (cross-document view transitions; a browser without them just loads the page). Off under reduced motion. */++@view-transition { navigation: auto; }++::view-transition-old(root), ::view-transition-new(root) { animation-duration: .18s; }++#navbar-mount, .openvibe-navbar { view-transition-name: ov-navbar; }++.ovnav-appbar { view-transition-name: ov-appbar; }++@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }@@@ -815,7 +815,28 @@const page = ({ head: h = '', body = '', side = '', dock: d = '', wide = false, id = 'main' } = {}) =>`<main class="ovk-page${wide ? ' ovk-page--wide' : ''}" id="${esc(id)}">${h}${side ? `<div class="ovk-split"><div class="ovk-main">${body}</div><aside class="ovk-side">${side}</aside></div>` : body}${d}</main>`;++ /**++ * Placeholders shaped like the content on its way (4.70.0, plan L52; the owner, 2026-11-01: "placeholder elements while++ * it loads, a fake thumbnail, so it is not jarring"). Same sizes as the real thing, so it lands without a jump; a soft++ * shimmer (never a spinner), still under reduced motion; announced once as busy.++ * skeleton({ kind: 'card' | 'row' | 'tile' | 'chip' | 'line' | 'chat' | 'stat', count = 3, label = 'Loading' })++ */++ function skeleton({ kind = 'row', count = 3, label = 'Loading' } = {}) {++ const n = Math.max(1, Math.min(48, Number(count) || 1));++ const one = {++ card: '<div class="ovk-skel-card"><span class="ovk-skel ovk-skel-thumb"></span><span class="ovk-skel ovk-skel-l1"></span><span class="ovk-skel ovk-skel-l2"></span></div>',++ row: '<div class="ovk-skel-row"><span class="ovk-skel ovk-skel-avatar"></span><span class="ovk-skel-lines"><span class="ovk-skel ovk-skel-l1"></span><span class="ovk-skel ovk-skel-l2"></span></span></div>',++ tile: '<span class="ovk-skel ovk-skel-tile"></span>',++ chip: '<span class="ovk-skel ovk-skel-chip"></span>',++ line: '<span class="ovk-skel ovk-skel-line"></span>',++ chat: '<div class="ovk-skel-chat"><span class="ovk-skel ovk-skel-dot"></span><span class="ovk-skel ovk-skel-l1"></span></div>',++ stat: '<div class="ovk-skel-stat"><span class="ovk-skel ovk-skel-num"></span><span class="ovk-skel ovk-skel-l2"></span></div>',++ }[kind] || '<span class="ovk-skel ovk-skel-line"></span>';++ return `<div class="ovk-skels ovk-skels-${esc(kind)}" role="status" aria-busy="true" aria-label="${esc(label)}">${one.repeat(n)}</div>`;++ }++return {++ skeleton,esc, url, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,@@@ -476,7 +476,7 @@const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '82afb8a8cb4d', 'ov-glyphs.js': '8cece63e3796', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '220c2bf637aa', 'ov-fx.js': '46fce1e2b8db' };++ const SIBLING_REV = { 'ov-mark.js': '82afb8a8cb4d', 'ov-glyphs.js': '7d1c9a6e3934', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '220c2bf637aa', 'ov-fx.js': '46fce1e2b8db' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -2009,11 +2009,55 @@});}++ // ── Moving between pages (4.70.0, plan L52; the owner, 2026-11-01: "make navigation feel better… the logo changing++ // while it loads… smarter loading") ──++ // The mark is the loading indicator: it works from the tap until the next page is in (OVMark.navStart/navEnd; a++ // single-page app calls OpenVibeNavbar.busy). Same-site pages are fetched ahead on hover or press (speculation rules,++ // where the browser has them), never sign-in, sign-out, the API or a download.++ let _movesHooked = false;++ function hookMoves() {++ if (_movesHooked || typeof document === 'undefined' || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;++ _movesHooked = true;++ // A boosted site prefetches through boost.js already (and Chrome's prefetch beside it kept parsed pages alive).++ if (_config.speculate !== false && !root.OVBoost && !document.querySelector('meta[name="ov-boost"]') && typeof HTMLScriptElement !== 'undefined' && HTMLScriptElement.supports && HTMLScriptElement.supports('speculationrules') && !document.querySelector('script[type="speculationrules"]')) {++ try {++ const sr = document.createElement('script');++ sr.type = 'speculationrules';++ sr.textContent = JSON.stringify({ prefetch: [{ where: { and: [{ href_matches: '/*' }, { not: { href_matches: ['/auth/*', '/api/*', '/logout*', '/*logout*', '/signout*', '/internal/*', '/*.zip', '/*.pdf'] } }, { not: { selector_matches: '[data-no-prefetch], [download], [rel~=nofollow]' } }] }, eagerness: 'moderate' }] });++ document.head.appendChild(sr);++ } catch { /* an old browser: links load as they always did */ }++ }++ // A plain same-site link on a server-rendered page: the mark starts working at once; the next page's mark takes over.++ document.addEventListener('click', (e) => {++ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;++ const a = e.target && e.target.closest && e.target.closest('a[href]');++ if (!a || a.target === '_blank' || a.hasAttribute('download')) return;++ let u; try { u = new URL(a.href, location.href); } catch { return; }++ if (u.origin !== location.origin || (u.pathname === location.pathname && u.search === location.search)) return;++ setTimeout(() => { if (!e.defaultPrevented) busy(true); }, 0); // after the page's own handler (an SPA's router) had its say++ }, true);++ // Back from the browser's page cache: whatever was loading is done.++ window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) busy(false); });++ }++ let _busyTimer = 0, _busyOn = false;++ function busy(on) {++ clearTimeout(_busyTimer);++ if (!on && !_busyOn) return; // only ends what it started (the mark keeps nothing it was not asked to)++ _busyOn = !!on;++ // A link that turns out to be a download never leaves the page: the mark gives up after ten seconds.++ if (on) _busyTimer = setTimeout(() => busy(false), 10000);++ try { if (window.OVMark) { if (on) window.OVMark.navStart(); else window.OVMark.navEnd(); } } catch { /* */ }++ }++const OpenVibeNavbar = {++ /** The mark works while something loads (an SPA's route, a long action): busy(true), then busy(false). */++ busy(on) { busy(!!on); },++init(opts = {}) {Object.assign(_config, opts);injectStyles();hookBoost();++ hookMoves();const el = render();upgradeIconsWhenFontsReady();if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);@@@ -27,7 +27,7 @@// Font Awesome's style, size, layout and animation classes: they say how, not what.const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|sharp-duotone|classic|brands|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|border|inverse|li|ul|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity|width-auto|width-fixed)$/;const BUSY = '<circle cx="6" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="18" cy="12" r="1.8"/>';-- const CSS = ".ovg::before,.ovg::after{content:none!important;display:none!important}.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-family:inherit!important;font-style:normal!important}.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25));stroke-linecap:round;stroke-linejoin:round;overflow:visible}.ovg>svg.has-s{stroke-width:min(var(--ovg-stroke,2) * 1px, max(0px, (1.15em - 18.4px) * 999))}.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - 18.4px) * 999))}.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}.ovg>svg>.ovg-s,.ovg>svg>.ovg-s *{stroke-dasharray:none!important;animation:none!important}.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}.ovg.fa-1x{font-size:1em}.ovg.fa-2x{font-size:2em}.ovg.fa-3x{font-size:3em}.ovg.fa-4x{font-size:4em}.ovg.fa-5x{font-size:5em}.ovg.fa-6x{font-size:6em}.ovg.fa-7x{font-size:7em}.ovg.fa-8x{font-size:8em}.ovg.fa-9x{font-size:9em}.ovg.fa-10x{font-size:10em}.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-border{box-sizing:content-box;border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);border-radius:var(--fa-border-radius,.1em);padding:var(--fa-border-padding,.2em .25em .15em)}.ovg.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.ovg.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.ovg.fa-rotate-90>svg{transform:rotate(90deg)}.ovg.fa-rotate-180>svg{transform:rotate(180deg)}.ovg.fa-rotate-270>svg{transform:rotate(270deg)}.ovg.fa-rotate-by>svg{transform:rotate(var(--fa-rotate-angle,0))}.ovg.fa-flip-horizontal>svg{transform:scale(-1,1)}.ovg.fa-flip-vertical>svg{transform:scale(1,-1)}.ovg.fa-flip-both>svg{transform:scale(-1,-1)}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{position:absolute;left:calc(var(--fa-li-width,2em)*-1);width:var(--fa-li-width,2em);text-align:center;line-height:inherit}.fa-stack{display:inline-block;position:relative;width:2.5em;height:2em;line-height:2em;vertical-align:middle}.fa-stack>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-stack>.ovg.fa-stack-2x{font-size:2em}.ovg.fa-inverse{color:var(--fa-inverse,#fff)}.fa-layers{display:inline-block;position:relative;width:1em;height:1em;text-align:center;vertical-align:-.125em}.fa-layers>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-layers-text{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:inline-block}.fa-layers-counter{position:absolute;top:var(--fa-top,0);right:var(--fa-right,0);display:inline-block;box-sizing:border-box;min-width:var(--fa-counter-min-width,1.5em);max-width:var(--fa-counter-max-width,5em);padding:var(--fa-counter-padding,.25em .5em);border-radius:var(--fa-counter-border-radius,1em);background:var(--fa-counter-background-color,#ff253a);color:var(--fa-inverse,#fff);line-height:var(--fa-counter-line-height,1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transform:scale(var(--fa-counter-scale,.25));transform-origin:top right}.ovg.fa-beat{animation:ovgBeat var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-bounce{animation:ovgBounce var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-fade{animation:ovgFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-beat-fade{animation:ovgBeatFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-flip{animation:ovgFlip var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-shake{animation:ovgShake var(--fa-animation-duration,1s) var(--fa-animation-timing,linear) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:ovgBreathe var(--fa-animation-duration,1.6s) ease-in-out var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite)!important}.ovg[data-ovg=busy].fa-spin,.ovg[data-ovg=busy].fa-pulse,.ovg[data-ovg=busy].fa-spin-pulse{animation:none!important}@keyframes ovgBeat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes ovgBounce{0%{transform:scale(1,1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1,1) translateY(var(--fa-bounce-rebound,-.125em))}64%,100%{transform:scale(1,1) translateY(0)}}@keyframes ovgFade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes ovgBeatFade{0%,100%{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes ovgFlip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes ovgShake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,100%{transform:rotate(0)}}@keyframes ovgBreathe{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.9)}}.ovg.ovg-draw>svg>*{stroke-dasharray:1;stroke-dashoffset:1;animation:ovgDraw var(--ovg-draw-duration,.6s) cubic-bezier(.4,0,.2,1) var(--ovg-draw-delay,0s) forwards}.ovg.ovg-draw>svg>*:nth-child(2){animation-delay:calc(var(--ovg-draw-delay,0s) + .12s)}.ovg.ovg-draw>svg>*:nth-child(n+3){animation-delay:calc(var(--ovg-draw-delay,0s) + .24s)}@keyframes ovgDraw{to{stroke-dashoffset:0}}.ovg.ovg-once{--fa-animation-iteration-count:1}.ovg[data-ovg=busy]>svg{fill:currentColor;stroke:none}.ovg[data-ovg=busy] circle{animation:ovgHop 1s ease-in-out infinite;transform-box:fill-box;transform-origin:center}.ovg[data-ovg=busy] circle:nth-child(2){animation-delay:.15s}.ovg[data-ovg=busy] circle:nth-child(3){animation-delay:.3s}@keyframes ovgHop{0%,60%,100%{transform:translateY(0);opacity:.55}30%{transform:translateY(-3px);opacity:1}}@media (prefers-reduced-motion:reduce){.ovg,.ovg[data-ovg=busy] circle,.ovg.ovg-draw>svg>*{animation:none!important;stroke-dashoffset:0!important}}html[data-ov-motion=reduced] .ovg,html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle,html[data-ov-motion=reduced] .ovg.ovg-draw>svg>*{animation:none!important;stroke-dashoffset:0!important}";++ const CSS = ".ovg::before,.ovg::after{content:none!important;display:none!important}.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-family:inherit!important;font-style:normal!important}.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25));stroke-linecap:round;stroke-linejoin:round;overflow:visible}.ovg>svg.has-s{stroke-width:min(var(--ovg-stroke,2) * 1px, max(0px, (1.15em - 18.4px) * 999))}.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - 18.4px) * 999))}.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}.ovg>svg>.ovg-s,.ovg>svg>.ovg-s *{stroke-dasharray:none!important;animation:none!important}.ovg.ovg-solid>svg.has-s{stroke-width:0}.ovg.ovg-solid>svg>.ovg-s{clip-path:none}.ovg.ovg-line>svg.has-s{stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25))}.ovg.ovg-line>svg>.ovg-s{clip-path:inset(50%)}:where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg.has-s{stroke-width:0}:where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg>.ovg-s{clip-path:none}.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}.ovg.fa-1x{font-size:1em}.ovg.fa-2x{font-size:2em}.ovg.fa-3x{font-size:3em}.ovg.fa-4x{font-size:4em}.ovg.fa-5x{font-size:5em}.ovg.fa-6x{font-size:6em}.ovg.fa-7x{font-size:7em}.ovg.fa-8x{font-size:8em}.ovg.fa-9x{font-size:9em}.ovg.fa-10x{font-size:10em}.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-border{box-sizing:content-box;border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);border-radius:var(--fa-border-radius,.1em);padding:var(--fa-border-padding,.2em .25em .15em)}.ovg.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.ovg.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.ovg.fa-rotate-90>svg{transform:rotate(90deg)}.ovg.fa-rotate-180>svg{transform:rotate(180deg)}.ovg.fa-rotate-270>svg{transform:rotate(270deg)}.ovg.fa-rotate-by>svg{transform:rotate(var(--fa-rotate-angle,0))}.ovg.fa-flip-horizontal>svg{transform:scale(-1,1)}.ovg.fa-flip-vertical>svg{transform:scale(1,-1)}.ovg.fa-flip-both>svg{transform:scale(-1,-1)}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{position:absolute;left:calc(var(--fa-li-width,2em)*-1);width:var(--fa-li-width,2em);text-align:center;line-height:inherit}.fa-stack{display:inline-block;position:relative;width:2.5em;height:2em;line-height:2em;vertical-align:middle}.fa-stack>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-stack>.ovg.fa-stack-2x{font-size:2em}.ovg.fa-inverse{color:var(--fa-inverse,#fff)}.fa-layers{display:inline-block;position:relative;width:1em;height:1em;text-align:center;vertical-align:-.125em}.fa-layers>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-layers-text{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:inline-block}.fa-layers-counter{position:absolute;top:var(--fa-top,0);right:var(--fa-right,0);display:inline-block;box-sizing:border-box;min-width:var(--fa-counter-min-width,1.5em);max-width:var(--fa-counter-max-width,5em);padding:var(--fa-counter-padding,.25em .5em);border-radius:var(--fa-counter-border-radius,1em);background:var(--fa-counter-background-color,#ff253a);color:var(--fa-inverse,#fff);line-height:var(--fa-counter-line-height,1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transform:scale(var(--fa-counter-scale,.25));transform-origin:top right}.ovg.fa-beat{animation:ovgBeat var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-bounce{animation:ovgBounce var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-fade{animation:ovgFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-beat-fade{animation:ovgBeatFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-flip{animation:ovgFlip var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-shake{animation:ovgShake var(--fa-animation-duration,1s) var(--fa-animation-timing,linear) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:ovgBreathe var(--fa-animation-duration,1.6s) ease-in-out var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite)!important}.ovg[data-ovg=busy].fa-spin,.ovg[data-ovg=busy].fa-pulse,.ovg[data-ovg=busy].fa-spin-pulse{animation:none!important}@keyframes ovgBeat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes ovgBounce{0%{transform:scale(1,1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1,1) translateY(var(--fa-bounce-rebound,-.125em))}64%,100%{transform:scale(1,1) translateY(0)}}@keyframes ovgFade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes ovgBeatFade{0%,100%{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes ovgFlip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes ovgShake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,100%{transform:rotate(0)}}@keyframes ovgBreathe{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.9)}}.ovg.ovg-draw>svg>*{stroke-dasharray:1;stroke-dashoffset:1;animation:ovgDraw var(--ovg-draw-duration,.6s) cubic-bezier(.4,0,.2,1) var(--ovg-draw-delay,0s) forwards}.ovg.ovg-draw>svg>*:nth-child(2){animation-delay:calc(var(--ovg-draw-delay,0s) + .12s)}.ovg.ovg-draw>svg>*:nth-child(n+3){animation-delay:calc(var(--ovg-draw-delay,0s) + .24s)}@keyframes ovgDraw{to{stroke-dashoffset:0}}.ovg.ovg-once{--fa-animation-iteration-count:1}.ovg[data-ovg=busy]>svg{fill:currentColor;stroke:none}.ovg[data-ovg=busy] circle{animation:ovgHop 1s ease-in-out infinite;transform-box:fill-box;transform-origin:center}.ovg[data-ovg=busy] circle:nth-child(2){animation-delay:.15s}.ovg[data-ovg=busy] circle:nth-child(3){animation-delay:.3s}@keyframes ovgHop{0%,60%,100%{transform:translateY(0);opacity:.55}30%{transform:translateY(-3px);opacity:1}}@media (prefers-reduced-motion:reduce){.ovg,.ovg[data-ovg=busy] circle,.ovg.ovg-draw>svg>*{animation:none!important;stroke-dashoffset:0!important}}html[data-ov-motion=reduced] .ovg,html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle,html[data-ov-motion=reduced] .ovg.ovg-draw>svg>*{animation:none!important;stroke-dashoffset:0!important}";const nameOf = (el) => { for (const c of el.classList) if (c.startsWith('fa-') && !MOD.test(c)) return c.slice(3); return null; };const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) ? FA[n] : null; };// data-fa-transform, in Font Awesome's units (a sixteenth of the icon).@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.69.0",++ "version": "4.70.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -31,6 +31,14 @@ const CSS = ['.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - 18.4px) * 999))}','.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}','.ovg>svg>.ovg-s,.ovg>svg>.ovg-s *{stroke-dasharray:none!important;animation:none!important}',++ // Line or solid on purpose (4.70.0; the owner, 2026-11-01: "both sets for any size… swap between them… whichever++ // fits the area"): .ovg-solid and .ovg-line force one at any size, and an active control (the current tab, a pressed++ // or selected button) wears the solid one, the way an app's tab bar fills the tab you are on. :where() keeps these++ // easy for a site to override.++ '.ovg.ovg-solid>svg.has-s{stroke-width:0}.ovg.ovg-solid>svg>.ovg-s{clip-path:none}',++ '.ovg.ovg-line>svg.has-s{stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25))}.ovg.ovg-line>svg>.ovg-s{clip-path:inset(50%)}',++ ':where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg.has-s{stroke-width:0}',++ ':where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg>.ovg-s{clip-path:none}','.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}',// Sizes, fixed width, borders and pulls.'.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}',@@@ -9,6 +9,6 @@},"dependencies": {"express": "^4.22.3",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.35.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.69.0"}}@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 41 * 1024; // 40.4 KB with the Dock's loader, its account-menu tile and openDock (4.51.0; the pill and the Dock are dock.js, loaded for signed-in people only); 39.8 KB with the site switcher's loader and visit (4.48.0); 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)++const NAVBAR_BROTLI_BUDGET = 42 * 1024; // 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)// ── size budget and generated block ──────────────────────────{
@@ -4,7 +4,32 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,7 +4,32 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
-## 4.69.0
+## 4.70.0 — 2026-11-01
+
+- **Line or solid, on purpose** (plan L49; the owner, 2026-11-01: "both sets for any size… swap between them…
+ whichever fits the area"). `.ovg-solid` and `.ovg-line` force one at any size. An active control (the current tab,
+ a pressed or selected button, `aria-current`, `aria-pressed`, `aria-selected`, `.is-here`) wears the solid drawing,
+ as an app's tab bar fills the tab you are on. Kept low in specificity (`:where`) so a site can override it.
+- **The app bar feels like an app's** (plan L52; "improve the mobile button so it feels seamless and smooth"):
+ - it is drawn once and kept between pages;
+ - one pill slides to the tab you are on;
+ - a tap lights its tab at once, before the page arrives;
+ - a press gives, and the new tab's icon pops.
+- **Placeholders shaped like the content** ("placeholder elements while it loads, a fake thumbnail, so it is not
+ jarring"):
+ - `kit.skeleton({ kind, count })` with seven shapes: card (16:9), row, tile, chip, line, chat and stat;
+ - kit.css shimmer, never a spinner, still under reduced motion and Calm;
+ - announced as busy.
+- **Moving between pages** ("the logo changing while it loads… smarter loading"):
+ - `OpenVibeNavbar.busy(on)`: the mark works while something loads;
+ - a plain same-site link starts the mark at once, and the next page's mark takes over (a download gives up after
+ 10 s);
+ - speculation rules prefetch same-site pages on hover or press (`moderate`), never /auth, /api, sign-out or a
+ download (`speculate: false` turns it off);
+ - cross-document view transitions keep the navbar and app bar still while a server-rendered page cross-fades (off
+ under reduced motion).
+
+## 4.69.0 — 2026-11-01
- **Icons that read at every size** (plan L49; the owner, 2026-11-01: "really tiny icons… a solid colour rather than
- **Icons that read at every size** (plan L49; the owner, 2026-11-01: "really tiny icons… a solid colour rather than
the border lines… so all our icons look nice across different sizes and devices"). 48 glyphs most often shown small
the border lines… so all our icons look nice across different sizes and devices"). 48 glyphs most often shown small
@@ -17,7 +42,7 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -17,7 +42,7 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
(sheets, modals, full-screen chats), `.ov-scroll-x` scrolls sideways only. On a phone the kit's sidebar sections
(sheets, modals, full-screen chats), `.ov-scroll-x` scrolls sideways only. On a phone the kit's sidebar sections
no longer trap the page.
no longer trap the page.
-## 4.68.0
+## 4.68.0 — 2026-11-01
- **83 more glyphs, 446 Font Awesome names** (plan L49 step 1; the owner, 2026-11-01: "why Font Awesome? We should be
- **83 more glyphs, 446 Font Awesome names** (plan L49 step 1; the owner, 2026-11-01: "why Font Awesome? We should be
using OpenVibe Design's icon system for all our icons"). Every non-brand name Live uses now has a glyph: trend up and
using OpenVibe Design's icon system for all our icons"). Every non-brand name Live uses now has a glyph: trend up and
@@ -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.69.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.70.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.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.69.0",
+ "version": "4.70.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)",
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
(function (root) {
(function (root) {
'use strict';
'use strict';
let barEl = null;
let barEl = null;
+ let resizeBound = false;
function spec(ctx) {
function spec(ctx) {
const cfg = ctx.config.appBar;
const cfg = ctx.config.appBar;
@@ -49,7 +50,19 @@
@@ -49,7 +50,19 @@
.ovnav-appbar .ovnav-ab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -4px; border-radius: 12px; }
.ovnav-appbar .ovnav-ab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -4px; border-radius: 12px; }
.ovnav-appbar .ovnav-ab.is-here { color: var(--text-primary, #e8ecf3); }
.ovnav-appbar .ovnav-ab.is-here { color: var(--text-primary, #e8ecf3); }
.ovnav-appbar .ovnav-ab-ic { position: relative; display: grid; place-items: center; width: 52px; height: 28px; border-radius: 999px; font-size: 18px; transition: background .15s; }
.ovnav-appbar .ovnav-ab-ic { position: relative; display: grid; place-items: center; width: 52px; height: 28px; border-radius: 999px; font-size: 18px; transition: background .15s; }
- .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic { background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic { color: var(--accent-light, var(--accent, #60a5fa)); }
+ /* 4.70.0 (the owner, 2026-11-01: "improve the mobile button so it feels seamless and smooth"): one pill slides to the
+ tab you are on (the bar is kept between pages, so it can), a tap lights its tab at once, a press gives and the new
+ tab's icon pops. */
+ .ovnav-appbar .ovnav-ab-pill { position: absolute; left: 0; top: 0; width: 52px; height: 28px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); transform: translate(var(--ab-x, 0px), var(--ab-y, 6px)); transition: transform .42s cubic-bezier(.2,1.25,.3,1), opacity .2s; pointer-events: none; }
+ .ovnav-appbar.ab-still .ovnav-ab-pill { transition: none; }
+ .ovnav-appbar:not(:has(.is-here)) .ovnav-ab-pill { opacity: 0; }
+ .ovnav-appbar .ovnav-ab { -webkit-tap-highlight-color: transparent; }
+ .ovnav-appbar .ovnav-ab-ic, .ovnav-appbar .ovnav-ab-l { position: relative; transition: transform .2s cubic-bezier(.2,1.4,.3,1), color .2s; }
+ .ovnav-appbar .ovnav-ab:active .ovnav-ab-ic { transform: scale(.88); }
+ .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic svg { animation: ovAbPop .45s cubic-bezier(.2,1.5,.3,1); }
+ .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-l { font-weight: 700; }
+ @keyframes ovAbPop { 0% { transform: scale(.7); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } }
.ovnav-appbar .ovnav-ab-ic svg { width: 1em; height: 1em; }
.ovnav-appbar .ovnav-ab-ic svg { width: 1em; height: 1em; }
.ovnav-appbar .ovnav-ab-l { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-appbar .ovnav-ab-l { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-appbar .ovnav-ab-dot { position: absolute; top: 2px; right: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 2px var(--bg-secondary, #121826); }
.ovnav-appbar .ovnav-ab-dot { position: absolute; top: 2px; right: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 2px var(--bg-secondary, #121826); }
@@ -57,24 +70,50 @@
@@ -57,24 +70,50 @@
html.ov-has-appbar:has(:is(input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select, [contenteditable="true"]):focus) .ovnav-appbar,
html.ov-has-appbar:has(:is(input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select, [contenteditable="true"]):focus) .ovnav-appbar,
body.ov-panel-open .ovnav-appbar { transform: translateY(110%); }
body.ov-panel-open .ovnav-appbar { transform: translateY(110%); }
}
}
- @media (prefers-reduced-motion: reduce) { .ovnav-appbar, .ovnav-appbar .ovnav-ab-ic { transition: none; } }
+ @media (prefers-reduced-motion: reduce) { .ovnav-appbar, .ovnav-appbar .ovnav-ab-ic, .ovnav-appbar .ovnav-ab-pill { transition: none; } .ovnav-appbar .ovnav-ab-ic svg { animation: none !important; } }
@media print { .ovnav-appbar { display: none !important; } }`;
@media print { .ovnav-appbar { display: none !important; } }`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
+ /** Slide the pill under the lit tab's icon (the bar is its offset parent). */
+ function placePill(bar, instant) {
+ const pill = bar && bar.querySelector('.ovnav-ab-pill');
+ const ic = bar && bar.querySelector('.ovnav-ab.is-here .ovnav-ab-ic');
+ if (!pill || !ic) return;
+ if (instant) bar.classList.add('ab-still');
+ bar.style.setProperty('--ab-x', `${ic.offsetLeft + (ic.offsetWidth - 52) / 2}px`);
+ bar.style.setProperty('--ab-y', `${ic.offsetTop}px`);
+ const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16);
+ if (instant) raf(() => raf(() => bar.classList.remove('ab-still')));
+ }
+ /** Light one tab (and only it), as the page it opens. */
+ function light(bar, a) {
+ bar.querySelectorAll('.ovnav-ab').forEach((t) => { const on = t === a; t.classList.toggle('is-here', on); if (on) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current'); });
+ placePill(bar);
+ }
+ const keyOf = (s) => s.items.map((l) => `${l.href}|${l.label}|${l.icon || ''}|${l.dot ? 1 : 0}`).join(',') + (s.more ? '+more' : '');
+
/** Draw (or take away) the bar for the navbar's current state: { config, links, activePage, nav, navIcon, escapeAttr, escapeHtml, flatLinks }. */
/** Draw (or take away) the bar for the navbar's current state: { config, links, activePage, nav, navIcon, escapeAttr, escapeHtml, flatLinks }. */
function render(ctx) {
function render(ctx) {
if (typeof document === 'undefined' || !document.body) return;
if (typeof document === 'undefined' || !document.body) return;
- if (barEl) { barEl.remove(); barEl = null; }
const s = spec(ctx);
const s = spec(ctx);
const de = document.documentElement;
const de = document.documentElement;
+ // The same tabs as before: keep the bar (no flash, no jump) and move only what is lit, so the pill slides.
+ if (barEl && barEl.isConnected && s && s.items.length >= 2 && barEl.getAttribute('data-ab-key') === keyOf(s)) {
+ const tabs = barEl.querySelectorAll('a.ovnav-ab');
+ const lit = s.items.findIndex((l) => l.active);
+ light(barEl, lit >= 0 ? tabs[lit] : null);
+ return;
+ }
+ if (barEl) { barEl.remove(); barEl = null; }
if (!s || s.items.length < 2) { if (de && de.classList) de.classList.remove('ov-has-appbar'); return; }
if (!s || s.items.length < 2) { if (de && de.classList) de.classList.remove('ov-has-appbar'); return; }
styles();
styles();
const { navIcon, escapeAttr, escapeHtml } = ctx;
const { navIcon, escapeAttr, escapeHtml } = ctx;
const bar = document.createElement('nav');
const bar = document.createElement('nav');
bar.className = 'ovnav-appbar';
bar.className = 'ovnav-appbar';
bar.setAttribute('aria-label', 'App');
bar.setAttribute('aria-label', 'App');
- bar.innerHTML = s.items.map((l) => `<a href="${escapeAttr(l.href)}" class="ovnav-ab${l.active ? ' is-here' : ''}"${l.active ? ' aria-current="page"' : ''}${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}><span class="ovnav-ab-ic">${navIcon(l.icon || 'fa-circle')}${l.dot ? '<span class="ovnav-ab-dot" aria-label="new"></span>' : ''}</span><span class="ovnav-ab-l">${escapeHtml(l.label)}</span></a>`).join('')
+ bar.setAttribute('data-ab-key', keyOf(s));
+ bar.innerHTML = '<span class="ovnav-ab-pill" aria-hidden="true"></span>' + s.items.map((l) => `<a href="${escapeAttr(l.href)}" class="ovnav-ab${l.active ? ' is-here' : ''}"${l.active ? ' aria-current="page"' : ''}${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}><span class="ovnav-ab-ic">${navIcon(l.icon || 'fa-circle')}${l.dot ? '<span class="ovnav-ab-dot" aria-label="new"></span>' : ''}</span><span class="ovnav-ab-l">${escapeHtml(l.label)}</span></a>`).join('')
+ (s.more ? `<button type="button" class="ovnav-ab" data-ab-more aria-haspopup="true"><span class="ovnav-ab-ic">${navIcon('fa-bars')}</span><span class="ovnav-ab-l">More</span></button>` : '');
+ (s.more ? `<button type="button" class="ovnav-ab" data-ab-more aria-haspopup="true"><span class="ovnav-ab-ic">${navIcon('fa-bars')}</span><span class="ovnav-ab-l">More</span></button>` : '');
bar.addEventListener('click', (e) => {
bar.addEventListener('click', (e) => {
if (e.target.closest && e.target.closest('[data-ab-more]')) {
if (e.target.closest && e.target.closest('[data-ab-more]')) {
@@ -84,6 +123,7 @@
@@ -84,6 +123,7 @@
}
}
const a = e.target.closest && e.target.closest('a.ovnav-ab');
const a = e.target.closest && e.target.closest('a.ovnav-ab');
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ light(bar, a);
const id = a.getAttribute('data-link-id');
const id = a.getAttribute('data-link-id');
const item = id ? ctx.flatLinks(ctx.links).concat(Array.isArray(ctx.config.appBar) ? ctx.config.appBar : []).find((l) => l && l.id === id) : null;
const item = id ? ctx.flatLinks(ctx.links).concat(Array.isArray(ctx.config.appBar) ? ctx.config.appBar : []).find((l) => l && l.id === id) : null;
if (item && typeof item.onClick === 'function' && item.onClick(e) === false) e.preventDefault();
if (item && typeof item.onClick === 'function' && item.onClick(e) === false) e.preventDefault();
@@ -96,6 +136,8 @@
@@ -96,6 +136,8 @@
document.body.appendChild(bar);
document.body.appendChild(bar);
barEl = bar;
barEl = bar;
if (de && de.classList) de.classList.add('ov-has-appbar');
if (de && de.classList) de.classList.add('ov-has-appbar');
+ placePill(bar, true);
+ if (!resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') { resizeBound = true; window.addEventListener('resize', () => placePill(barEl, true)); }
}
}
const api = { render, spec };
const api = { render, spec };
@@ -332,3 +332,37 @@ div.ovk-set-row { cursor: default; }
@@ -332,3 +332,37 @@ div.ovk-set-row { cursor: default; }
@media (max-width: 900px), (hover: none) {
@media (max-width: 900px), (hover: none) {
.ovk-sidebar-secs { overscroll-behavior-y: auto; }
.ovk-sidebar-secs { overscroll-behavior-y: auto; }
}
}
+
+/* ── Placeholders shaped like the content (4.70.0, kit.skeleton; plan L52) ── A soft light passes over grey shapes the size of
+ what is coming; nothing spins; still under reduced motion and Calm. A list paints these in its first HTML and fills them in
+ place. */
+.ovk-skel { display: block; border-radius: 8px; background: color-mix(in srgb, var(--text-primary, #e8edf3) 7%, transparent); background-image: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--text-primary, #e8edf3) 9%, transparent) 50%, transparent 70%); background-size: 220% 100%; background-repeat: no-repeat; animation: ovkShimmer 1.6s ease-in-out infinite; }
+@keyframes ovkShimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }
+.ovk-skels { display: grid; gap: 12px; }
+.ovk-skels-card { grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }
+.ovk-skels-tile { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }
+.ovk-skels-chip { display: flex; flex-wrap: wrap; gap: 8px; }
+.ovk-skel-card { display: grid; gap: 8px; }
+.ovk-skel-thumb { aspect-ratio: 16 / 9; border-radius: 12px; }
+.ovk-skel-l1 { height: 13px; width: 86%; }
+.ovk-skel-l2 { height: 11px; width: 58%; }
+.ovk-skel-row { display: flex; align-items: center; gap: 12px; }
+.ovk-skel-avatar { flex: none; width: 40px; height: 40px; border-radius: 50%; }
+.ovk-skel-lines { flex: 1; display: grid; gap: 7px; min-width: 0; }
+.ovk-skel-tile { aspect-ratio: 1; border-radius: 14px; }
+.ovk-skel-chip { width: 84px; height: 30px; border-radius: 999px; }
+.ovk-skel-line { height: 12px; }
+.ovk-skel-chat { display: flex; align-items: center; gap: 8px; }
+.ovk-skel-dot { flex: none; width: 22px; height: 22px; border-radius: 50%; }
+.ovk-skel-stat { display: grid; gap: 6px; padding: 12px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.08)); }
+.ovk-skel-num { height: 22px; width: 48%; }
+@media (prefers-reduced-motion: reduce) { .ovk-skel { animation: none; } }
+html[data-ov-motion=reduced] .ovk-skel { animation: none; }
+
+/* ── Moving between pages (4.70.0, plan L52) ── A server-rendered site's pages cross-fade while the navbar and the app bar
+ stay put (cross-document view transitions; a browser without them just loads the page). Off under reduced motion. */
+@view-transition { navigation: auto; }
+::view-transition-old(root), ::view-transition-new(root) { animation-duration: .18s; }
+#navbar-mount, .openvibe-navbar { view-transition-name: ov-navbar; }
+.ovnav-appbar { view-transition-name: ov-appbar; }
+@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
@@ -815,7 +815,28 @@
@@ -815,7 +815,28 @@
const page = ({ head: h = '', body = '', side = '', dock: d = '', wide = false, id = 'main' } = {}) =>
const page = ({ head: h = '', body = '', side = '', dock: d = '', wide = false, id = 'main' } = {}) =>
`<main class="ovk-page${wide ? ' ovk-page--wide' : ''}" id="${esc(id)}">${h}${side ? `<div class="ovk-split"><div class="ovk-main">${body}</div><aside class="ovk-side">${side}</aside></div>` : body}${d}</main>`;
`<main class="ovk-page${wide ? ' ovk-page--wide' : ''}" id="${esc(id)}">${h}${side ? `<div class="ovk-split"><div class="ovk-main">${body}</div><aside class="ovk-side">${side}</aside></div>` : body}${d}</main>`;
+ /**
+ * Placeholders shaped like the content on its way (4.70.0, plan L52; the owner, 2026-11-01: "placeholder elements while
+ * it loads, a fake thumbnail, so it is not jarring"). Same sizes as the real thing, so it lands without a jump; a soft
+ * shimmer (never a spinner), still under reduced motion; announced once as busy.
+ * skeleton({ kind: 'card' | 'row' | 'tile' | 'chip' | 'line' | 'chat' | 'stat', count = 3, label = 'Loading' })
+ */
+ function skeleton({ kind = 'row', count = 3, label = 'Loading' } = {}) {
+ const n = Math.max(1, Math.min(48, Number(count) || 1));
+ const one = {
+ card: '<div class="ovk-skel-card"><span class="ovk-skel ovk-skel-thumb"></span><span class="ovk-skel ovk-skel-l1"></span><span class="ovk-skel ovk-skel-l2"></span></div>',
+ row: '<div class="ovk-skel-row"><span class="ovk-skel ovk-skel-avatar"></span><span class="ovk-skel-lines"><span class="ovk-skel ovk-skel-l1"></span><span class="ovk-skel ovk-skel-l2"></span></span></div>',
+ tile: '<span class="ovk-skel ovk-skel-tile"></span>',
+ chip: '<span class="ovk-skel ovk-skel-chip"></span>',
+ line: '<span class="ovk-skel ovk-skel-line"></span>',
+ chat: '<div class="ovk-skel-chat"><span class="ovk-skel ovk-skel-dot"></span><span class="ovk-skel ovk-skel-l1"></span></div>',
+ stat: '<div class="ovk-skel-stat"><span class="ovk-skel ovk-skel-num"></span><span class="ovk-skel ovk-skel-l2"></span></div>',
+ }[kind] || '<span class="ovk-skel ovk-skel-line"></span>';
+ return `<div class="ovk-skels ovk-skels-${esc(kind)}" role="status" aria-busy="true" aria-label="${esc(label)}">${one.repeat(n)}</div>`;
+ }
+
return {
return {
+ skeleton,
esc, url, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
esc, url, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
@@ -476,7 +476,7 @@
@@ -476,7 +476,7 @@
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '82afb8a8cb4d', 'ov-glyphs.js': '8cece63e3796', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '220c2bf637aa', 'ov-fx.js': '46fce1e2b8db' };
+ const SIBLING_REV = { 'ov-mark.js': '82afb8a8cb4d', 'ov-glyphs.js': '7d1c9a6e3934', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '220c2bf637aa', 'ov-fx.js': '46fce1e2b8db' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -2009,11 +2009,55 @@
@@ -2009,11 +2009,55 @@
});
});
}
}
+ // ── Moving between pages (4.70.0, plan L52; the owner, 2026-11-01: "make navigation feel better… the logo changing
+ // while it loads… smarter loading") ──
+ // The mark is the loading indicator: it works from the tap until the next page is in (OVMark.navStart/navEnd; a
+ // single-page app calls OpenVibeNavbar.busy). Same-site pages are fetched ahead on hover or press (speculation rules,
+ // where the browser has them), never sign-in, sign-out, the API or a download.
+ let _movesHooked = false;
+ function hookMoves() {
+ if (_movesHooked || typeof document === 'undefined' || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
+ _movesHooked = true;
+ // A boosted site prefetches through boost.js already (and Chrome's prefetch beside it kept parsed pages alive).
+ if (_config.speculate !== false && !root.OVBoost && !document.querySelector('meta[name="ov-boost"]') && typeof HTMLScriptElement !== 'undefined' && HTMLScriptElement.supports && HTMLScriptElement.supports('speculationrules') && !document.querySelector('script[type="speculationrules"]')) {
+ try {
+ const sr = document.createElement('script');
+ sr.type = 'speculationrules';
+ sr.textContent = JSON.stringify({ prefetch: [{ where: { and: [{ href_matches: '/*' }, { not: { href_matches: ['/auth/*', '/api/*', '/logout*', '/*logout*', '/signout*', '/internal/*', '/*.zip', '/*.pdf'] } }, { not: { selector_matches: '[data-no-prefetch], [download], [rel~=nofollow]' } }] }, eagerness: 'moderate' }] });
+ document.head.appendChild(sr);
+ } catch { /* an old browser: links load as they always did */ }
+ }
+ // A plain same-site link on a server-rendered page: the mark starts working at once; the next page's mark takes over.
+ document.addEventListener('click', (e) => {
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ const a = e.target && e.target.closest && e.target.closest('a[href]');
+ if (!a || a.target === '_blank' || a.hasAttribute('download')) return;
+ let u; try { u = new URL(a.href, location.href); } catch { return; }
+ if (u.origin !== location.origin || (u.pathname === location.pathname && u.search === location.search)) return;
+ setTimeout(() => { if (!e.defaultPrevented) busy(true); }, 0); // after the page's own handler (an SPA's router) had its say
+ }, true);
+ // Back from the browser's page cache: whatever was loading is done.
+ window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) busy(false); });
+ }
+ let _busyTimer = 0, _busyOn = false;
+ function busy(on) {
+ clearTimeout(_busyTimer);
+ if (!on && !_busyOn) return; // only ends what it started (the mark keeps nothing it was not asked to)
+ _busyOn = !!on;
+ // A link that turns out to be a download never leaves the page: the mark gives up after ten seconds.
+ if (on) _busyTimer = setTimeout(() => busy(false), 10000);
+ try { if (window.OVMark) { if (on) window.OVMark.navStart(); else window.OVMark.navEnd(); } } catch { /* */ }
+ }
+
const OpenVibeNavbar = {
const OpenVibeNavbar = {
+ /** The mark works while something loads (an SPA's route, a long action): busy(true), then busy(false). */
+ busy(on) { busy(!!on); },
+
init(opts = {}) {
init(opts = {}) {
Object.assign(_config, opts);
Object.assign(_config, opts);
injectStyles();
injectStyles();
hookBoost();
hookBoost();
+ hookMoves();
const el = render();
const el = render();
upgradeIconsWhenFontsReady();
upgradeIconsWhenFontsReady();
if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
// Font Awesome's style, size, layout and animation classes: they say how, not what.
// Font Awesome's style, size, layout and animation classes: they say how, not what.
const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|sharp-duotone|classic|brands|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|border|inverse|li|ul|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity|width-auto|width-fixed)$/;
const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|sharp-duotone|classic|brands|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|border|inverse|li|ul|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity|width-auto|width-fixed)$/;
const BUSY = '<circle cx="6" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="18" cy="12" r="1.8"/>';
const BUSY = '<circle cx="6" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="18" cy="12" r="1.8"/>';
- const CSS = ".ovg::before,.ovg::after{content:none!important;display:none!important}.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-family:inherit!important;font-style:normal!important}.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25));stroke-linecap:round;stroke-linejoin:round;overflow:visible}.ovg>svg.has-s{stroke-width:min(var(--ovg-stroke,2) * 1px, max(0px, (1.15em - 18.4px) * 999))}.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - 18.4px) * 999))}.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}.ovg>svg>.ovg-s,.ovg>svg>.ovg-s *{stroke-dasharray:none!important;animation:none!important}.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}.ovg.fa-1x{font-size:1em}.ovg.fa-2x{font-size:2em}.ovg.fa-3x{font-size:3em}.ovg.fa-4x{font-size:4em}.ovg.fa-5x{font-size:5em}.ovg.fa-6x{font-size:6em}.ovg.fa-7x{font-size:7em}.ovg.fa-8x{font-size:8em}.ovg.fa-9x{font-size:9em}.ovg.fa-10x{font-size:10em}.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-border{box-sizing:content-box;border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);border-radius:var(--fa-border-radius,.1em);padding:var(--fa-border-padding,.2em .25em .15em)}.ovg.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.ovg.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.ovg.fa-rotate-90>svg{transform:rotate(90deg)}.ovg.fa-rotate-180>svg{transform:rotate(180deg)}.ovg.fa-rotate-270>svg{transform:rotate(270deg)}.ovg.fa-rotate-by>svg{transform:rotate(var(--fa-rotate-angle,0))}.ovg.fa-flip-horizontal>svg{transform:scale(-1,1)}.ovg.fa-flip-vertical>svg{transform:scale(1,-1)}.ovg.fa-flip-both>svg{transform:scale(-1,-1)}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{position:absolute;left:calc(var(--fa-li-width,2em)*-1);width:var(--fa-li-width,2em);text-align:center;line-height:inherit}.fa-stack{display:inline-block;position:relative;width:2.5em;height:2em;line-height:2em;vertical-align:middle}.fa-stack>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-stack>.ovg.fa-stack-2x{font-size:2em}.ovg.fa-inverse{color:var(--fa-inverse,#fff)}.fa-layers{display:inline-block;position:relative;width:1em;height:1em;text-align:center;vertical-align:-.125em}.fa-layers>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-layers-text{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:inline-block}.fa-layers-counter{position:absolute;top:var(--fa-top,0);right:var(--fa-right,0);display:inline-block;box-sizing:border-box;min-width:var(--fa-counter-min-width,1.5em);max-width:var(--fa-counter-max-width,5em);padding:var(--fa-counter-padding,.25em .5em);border-radius:var(--fa-counter-border-radius,1em);background:var(--fa-counter-background-color,#ff253a);color:var(--fa-inverse,#fff);line-height:var(--fa-counter-line-height,1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transform:scale(var(--fa-counter-scale,.25));transform-origin:top right}.ovg.fa-beat{animation:ovgBeat var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-bounce{animation:ovgBounce var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-fade{animation:ovgFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-beat-fade{animation:ovgBeatFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-flip{animation:ovgFlip var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-shake{animation:ovgShake var(--fa-animation-duration,1s) var(--fa-animation-timing,linear) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:ovgBreathe var(--fa-animation-duration,1.6s) ease-in-out var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite)!important}.ovg[data-ovg=busy].fa-spin,.ovg[data-ovg=busy].fa-pulse,.ovg[data-ovg=busy].fa-spin-pulse{animation:none!important}@keyframes ovgBeat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes ovgBounce{0%{transform:scale(1,1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1,1) translateY(var(--fa-bounce-rebound,-.125em))}64%,100%{transform:scale(1,1) translateY(0)}}@keyframes ovgFade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes ovgBeatFade{0%,100%{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes ovgFlip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes ovgShake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,100%{transform:rotate(0)}}@keyframes ovgBreathe{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.9)}}.ovg.ovg-draw>svg>*{stroke-dasharray:1;stroke-dashoffset:1;animation:ovgDraw var(--ovg-draw-duration,.6s) cubic-bezier(.4,0,.2,1) var(--ovg-draw-delay,0s) forwards}.ovg.ovg-draw>svg>*:nth-child(2){animation-delay:calc(var(--ovg-draw-delay,0s) + .12s)}.ovg.ovg-draw>svg>*:nth-child(n+3){animation-delay:calc(var(--ovg-draw-delay,0s) + .24s)}@keyframes ovgDraw{to{stroke-dashoffset:0}}.ovg.ovg-once{--fa-animation-iteration-count:1}.ovg[data-ovg=busy]>svg{fill:currentColor;stroke:none}.ovg[data-ovg=busy] circle{animation:ovgHop 1s ease-in-out infinite;transform-box:fill-box;transform-origin:center}.ovg[data-ovg=busy] circle:nth-child(2){animation-delay:.15s}.ovg[data-ovg=busy] circle:nth-child(3){animation-delay:.3s}@keyframes ovgHop{0%,60%,100%{transform:translateY(0);opacity:.55}30%{transform:translateY(-3px);opacity:1}}@media (prefers-reduced-motion:reduce){.ovg,.ovg[data-ovg=busy] circle,.ovg.ovg-draw>svg>*{animation:none!important;stroke-dashoffset:0!important}}html[data-ov-motion=reduced] .ovg,html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle,html[data-ov-motion=reduced] .ovg.ovg-draw>svg>*{animation:none!important;stroke-dashoffset:0!important}";
+ const CSS = ".ovg::before,.ovg::after{content:none!important;display:none!important}.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-family:inherit!important;font-style:normal!important}.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25));stroke-linecap:round;stroke-linejoin:round;overflow:visible}.ovg>svg.has-s{stroke-width:min(var(--ovg-stroke,2) * 1px, max(0px, (1.15em - 18.4px) * 999))}.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - 18.4px) * 999))}.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}.ovg>svg>.ovg-s,.ovg>svg>.ovg-s *{stroke-dasharray:none!important;animation:none!important}.ovg.ovg-solid>svg.has-s{stroke-width:0}.ovg.ovg-solid>svg>.ovg-s{clip-path:none}.ovg.ovg-line>svg.has-s{stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25))}.ovg.ovg-line>svg>.ovg-s{clip-path:inset(50%)}:where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg.has-s{stroke-width:0}:where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg>.ovg-s{clip-path:none}.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}.ovg.fa-1x{font-size:1em}.ovg.fa-2x{font-size:2em}.ovg.fa-3x{font-size:3em}.ovg.fa-4x{font-size:4em}.ovg.fa-5x{font-size:5em}.ovg.fa-6x{font-size:6em}.ovg.fa-7x{font-size:7em}.ovg.fa-8x{font-size:8em}.ovg.fa-9x{font-size:9em}.ovg.fa-10x{font-size:10em}.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-border{box-sizing:content-box;border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);border-radius:var(--fa-border-radius,.1em);padding:var(--fa-border-padding,.2em .25em .15em)}.ovg.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.ovg.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.ovg.fa-rotate-90>svg{transform:rotate(90deg)}.ovg.fa-rotate-180>svg{transform:rotate(180deg)}.ovg.fa-rotate-270>svg{transform:rotate(270deg)}.ovg.fa-rotate-by>svg{transform:rotate(var(--fa-rotate-angle,0))}.ovg.fa-flip-horizontal>svg{transform:scale(-1,1)}.ovg.fa-flip-vertical>svg{transform:scale(1,-1)}.ovg.fa-flip-both>svg{transform:scale(-1,-1)}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{position:absolute;left:calc(var(--fa-li-width,2em)*-1);width:var(--fa-li-width,2em);text-align:center;line-height:inherit}.fa-stack{display:inline-block;position:relative;width:2.5em;height:2em;line-height:2em;vertical-align:middle}.fa-stack>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-stack>.ovg.fa-stack-2x{font-size:2em}.ovg.fa-inverse{color:var(--fa-inverse,#fff)}.fa-layers{display:inline-block;position:relative;width:1em;height:1em;text-align:center;vertical-align:-.125em}.fa-layers>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-layers-text{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:inline-block}.fa-layers-counter{position:absolute;top:var(--fa-top,0);right:var(--fa-right,0);display:inline-block;box-sizing:border-box;min-width:var(--fa-counter-min-width,1.5em);max-width:var(--fa-counter-max-width,5em);padding:var(--fa-counter-padding,.25em .5em);border-radius:var(--fa-counter-border-radius,1em);background:var(--fa-counter-background-color,#ff253a);color:var(--fa-inverse,#fff);line-height:var(--fa-counter-line-height,1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transform:scale(var(--fa-counter-scale,.25));transform-origin:top right}.ovg.fa-beat{animation:ovgBeat var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-bounce{animation:ovgBounce var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-fade{animation:ovgFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-beat-fade{animation:ovgBeatFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-flip{animation:ovgFlip var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-shake{animation:ovgShake var(--fa-animation-duration,1s) var(--fa-animation-timing,linear) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:ovgBreathe var(--fa-animation-duration,1.6s) ease-in-out var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite)!important}.ovg[data-ovg=busy].fa-spin,.ovg[data-ovg=busy].fa-pulse,.ovg[data-ovg=busy].fa-spin-pulse{animation:none!important}@keyframes ovgBeat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes ovgBounce{0%{transform:scale(1,1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1,1) translateY(var(--fa-bounce-rebound,-.125em))}64%,100%{transform:scale(1,1) translateY(0)}}@keyframes ovgFade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes ovgBeatFade{0%,100%{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes ovgFlip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes ovgShake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,100%{transform:rotate(0)}}@keyframes ovgBreathe{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.9)}}.ovg.ovg-draw>svg>*{stroke-dasharray:1;stroke-dashoffset:1;animation:ovgDraw var(--ovg-draw-duration,.6s) cubic-bezier(.4,0,.2,1) var(--ovg-draw-delay,0s) forwards}.ovg.ovg-draw>svg>*:nth-child(2){animation-delay:calc(var(--ovg-draw-delay,0s) + .12s)}.ovg.ovg-draw>svg>*:nth-child(n+3){animation-delay:calc(var(--ovg-draw-delay,0s) + .24s)}@keyframes ovgDraw{to{stroke-dashoffset:0}}.ovg.ovg-once{--fa-animation-iteration-count:1}.ovg[data-ovg=busy]>svg{fill:currentColor;stroke:none}.ovg[data-ovg=busy] circle{animation:ovgHop 1s ease-in-out infinite;transform-box:fill-box;transform-origin:center}.ovg[data-ovg=busy] circle:nth-child(2){animation-delay:.15s}.ovg[data-ovg=busy] circle:nth-child(3){animation-delay:.3s}@keyframes ovgHop{0%,60%,100%{transform:translateY(0);opacity:.55}30%{transform:translateY(-3px);opacity:1}}@media (prefers-reduced-motion:reduce){.ovg,.ovg[data-ovg=busy] circle,.ovg.ovg-draw>svg>*{animation:none!important;stroke-dashoffset:0!important}}html[data-ov-motion=reduced] .ovg,html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle,html[data-ov-motion=reduced] .ovg.ovg-draw>svg>*{animation:none!important;stroke-dashoffset:0!important}";
const nameOf = (el) => { for (const c of el.classList) if (c.startsWith('fa-') && !MOD.test(c)) return c.slice(3); return null; };
const nameOf = (el) => { for (const c of el.classList) if (c.startsWith('fa-') && !MOD.test(c)) return c.slice(3); return null; };
const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) ? FA[n] : null; };
const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) ? FA[n] : null; };
// data-fa-transform, in Font Awesome's units (a sixteenth of the icon).
// data-fa-transform, in Font Awesome's units (a sixteenth of the icon).
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.69.0",
+ "version": "4.70.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": {
@@ -31,6 +31,14 @@ const CSS = [
@@ -31,6 +31,14 @@ const CSS = [
'.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - 18.4px) * 999))}',
'.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - 18.4px) * 999))}',
'.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}',
'.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}',
'.ovg>svg>.ovg-s,.ovg>svg>.ovg-s *{stroke-dasharray:none!important;animation:none!important}',
'.ovg>svg>.ovg-s,.ovg>svg>.ovg-s *{stroke-dasharray:none!important;animation:none!important}',
+ // Line or solid on purpose (4.70.0; the owner, 2026-11-01: "both sets for any size… swap between them… whichever
+ // fits the area"): .ovg-solid and .ovg-line force one at any size, and an active control (the current tab, a pressed
+ // or selected button) wears the solid one, the way an app's tab bar fills the tab you are on. :where() keeps these
+ // easy for a site to override.
+ '.ovg.ovg-solid>svg.has-s{stroke-width:0}.ovg.ovg-solid>svg>.ovg-s{clip-path:none}',
+ '.ovg.ovg-line>svg.has-s{stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25))}.ovg.ovg-line>svg>.ovg-s{clip-path:inset(50%)}',
+ ':where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg.has-s{stroke-width:0}',
+ ':where([aria-current=page],[aria-current=true],[aria-pressed=true],[aria-selected=true],.is-here)>.ovg:not(.ovg-line)>svg>.ovg-s{clip-path:none}',
'.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}',
'.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}',
// Sizes, fixed width, borders and pulls.
// Sizes, fixed width, borders and pulls.
'.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}',
'.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}',
@@ -9,6 +9,6 @@
@@ -9,6 +9,6 @@
},
},
"dependencies": {
"dependencies": {
"express": "^4.22.3",
"express": "^4.22.3",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.35.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.69.0"
}
}
}
}
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 41 * 1024; // 40.4 KB with the Dock's loader, its account-menu tile and openDock (4.51.0; the pill and the Dock are dock.js, loaded for signed-in people only); 39.8 KB with the site switcher's loader and visit (4.48.0); 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)
+const NAVBAR_BROTLI_BUDGET = 42 * 1024; // 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{