openvibers/openvibe.shared Commits

Line or solid on purpose; an app bar that slides; placeholders; the mark while pages load (4.70.0) (#222)

OP OpenVibers committed 12 days ago · 2b93a378b0 · 12 files, +187 −13

* Line or solid on purpose; an app bar that slides; placeholders shaped like the content; the mark while pages load (4.70.0)

The owner, 2026-11-01: both icon sets at any size, picked by the area; "improve the mobile button so it feels
seamless and smooth"; "placeholder elements while it loads… so it is not jarring"; "the logo changing while it
loads… smarter loading… navigation feel better". .ovg-solid/.ovg-line and active controls wearing solid; the app
bar kept between pages with a sliding pill, an instant tap, a press and a pop; kit.skeleton and its shapes;
OpenVibeNavbar.busy, the mark on plain links, speculation-rules prefetch and cross-document view transitions.

* 4.70.0: prefetch rules only on unboosted sites (boost prefetches already, and Chrome's prefetch beside it kept parsed pages alive); the mark ends only what it started; navbar budget 42 KB

* 4.70.0: date the 4.68–4.70 changelog entries; the site template on v4.69.0 (pin-drift)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2d47191..fa00a86 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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
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).
(sheets, modals, full-screen chats), `.ov-scroll-x` scrolls sideways only. On a phone the kit's sidebar sections
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
using OpenVibe Design's icon system for all our icons"). Every non-brand name Live uses now has a glyph: trend up and
ddiff --git a/README.md b/README.md
iindex 3a62b43..9da6748 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 0608980..4da715c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/appbar.js b/appbar.js
iindex 0920a6e..30d197b 100644
---- a/appbar.js
++++ b/appbar.js
@@@ -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 };
ddiff --git a/kit.css b/kit.css
iindex 742e023..9a6d54f 100644
---- a/kit.css
++++ b/kit.css
@@@ -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; } }
ddiff --git a/kit.js b/kit.js
iindex 9f99ffc..083d59c 100644
---- a/kit.js
++++ b/kit.js
@@@ -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,
ddiff --git a/navbar.js b/navbar.js
iindex bcd7120..54feb6c 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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);
ddiff --git a/ov-glyphs.js b/ov-glyphs.js
iindex fdf5334..cac7f0d 100644
---- a/ov-glyphs.js
++++ b/ov-glyphs.js
@@@ -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).
ddiff --git a/package.json b/package.json
iindex 3c2f61b..49f182c 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/scripts/build-glyphs.js b/scripts/build-glyphs.js
iindex 13ad1a0..c54df8b 100644
---- a/scripts/build-glyphs.js
++++ b/scripts/build-glyphs.js
@@@ -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}',
ddiff --git a/templates/site/package.json b/templates/site/package.json
iindex 3b61594..4395b35 100644
---- a/templates/site/package.json
++++ b/templates/site/package.json
@@@ -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"
}
}
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex fd083ac..cacb731 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -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 ──────────────────────────
{