openvibers/openvibe.shared Commits

4.197.0: the footer says "3 online", and the tooltip part works anywhere (#426)

OP OpenVibers committed 3 days ago · b36aacdcec · 9 files, +53 −17

* The footer says "3 online"; when and who are its tooltip and what a press opens

The tooltip part now loads at the first hover over anything that carries
one, not only the bar.

* 4.197.0: the footer says "3 online", and the tooltip part works anywhere

* The bar: the tooltip loader in fewer bytes (inside its size budget)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 500ad68..d20b656 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,21 @@
# Changelog
++## 4.197.0 — 2026-11-10
++
++**The footer says "3 online", and the tooltip part works anywhere.** From the owner's notes of 2026-11-10: "Make it like
++shorthand '3 online'. If you put your mouse over it, it says 'last 30 minutes'".
++
++- **The footer's online strip (`footer.js`)** read "13 online in the last 30 min", then the faces, then "1 member · 12
++ guests". It now says **"13 online"** and the faces. Hovering the count (or reaching it with the keyboard) shows the one
++ tooltip: "In the last 30 minutes", and under it how many are members and guests. A press opens the same words above
++ the list of names, because a finger never sees a tooltip; with guests only, the count still opens that one line
++ (before, a guests-only strip opened nothing).
++- **The tooltip part (`ov-tip.js`) loads at the first hover over anything that carries `data-ov-tip`**, not only over the
++ bar: the footer's count, and whatever a site labels the same way. The waiting listener lives at module level, so it
++ holds no bar that a soft navigation has replaced.
++- A test (`menu-places-chrome`) measured the menu's origin after a fixed wait and could read it mid-motion on a busy
++ machine; it now waits for the motion to end.
++
## 4.196.0 — 2026-11-10
**Your Actor in the Dock, redrawn: one row and one line to type in. And a tone is a lean and a glow, never a bar.** From
ddiff --git a/README.md b/README.md
iindex e9caefd..b051dc6 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.196.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.197.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 91b40cb..4aa2bc1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.196.0",
++ "version": "4.197.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/footer.js b/footer.js
iindex 342ecf6..9514198 100644
---- a/footer.js
++++ b/footer.js
@@@ -452,7 +452,7 @@
background:color-mix(in srgb, var(--accent,#60a5fa) 6%, var(--bg-tertiary,#1a1b28));border:1px solid var(--border,rgba(255,255,255,.08));animation:ovfIn .4s ease-out both}
.ovf-pulse a{color:inherit;text-decoration:none;font-weight:600}
.ovf-now{flex:1 1 100%;min-width:0;display:grid;gap:8px}
--.ovf-who>summary,.ovf-who--flat{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 9px;min-height:36px;color:var(--text-primary,#f1f4fb)}
++.ovf-who>summary{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 9px;min-height:36px;color:var(--text-primary,#f1f4fb)}
.ovf-who>summary{cursor:pointer;border-radius:10px}
.ovf-who>summary::-webkit-details-marker{display:none}
.ovf-who>summary::after{content:"";width:.45em;height:.45em;margin:0 4px 3px auto;border:solid var(--text-muted,#8b93ad);border-width:0 1.8px 1.8px 0;transform:rotate(45deg);transition:transform .2s}
@@@ -461,7 +461,7 @@
.ovf-who-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--success-text,#34d399);box-shadow:0 0 0 3px color-mix(in srgb,var(--success-text,#34d399) 22%,transparent);animation:ovf-on 2.4s ease-in-out infinite}
@keyframes ovf-on{50%{box-shadow:0 0 0 calc(3px + 3px*var(--ov-move,1)) color-mix(in srgb,var(--success-text,#34d399) 6%,transparent)}}
.ovf-who-n{font-weight:500}.ovf-who-n b{font-size:1.05em;font-weight:800;font-variant-numeric:tabular-nums}
--.ovf-who-sub{color:var(--text-muted,#8b93ad)}
++.ovf-who-when{margin:6px 2px 0;color:var(--text-muted,#8b93ad)}
.ovf-who-faces{display:inline-flex;padding-left:7px}
.ovf-who-faces img,.ovf-who-faces i{width:24px;height:24px;margin-left:-7px;border-radius:50%;box-shadow:0 0 0 2px var(--bg-secondary,#0f1420);object-fit:cover}
.ovf-who i{display:grid;place-items:center;font-style:normal;font-size:.72rem;font-weight:800;color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--accent,#3b82f6) 38%,var(--bg-secondary,#0f1420))}
@@@ -483,7 +483,7 @@ a.ovf-tile:active{transform:scale(.96)}
.ovf-tile .ovf-ic{font-size:1.15em;color:var(--accent-light,var(--accent,#60a5fa))}
.ovf-tile.is-live{background:color-mix(in srgb,var(--live-red-text,#ef4444) 15%,transparent)}
.ovf-tile.is-channel{padding:0 12px;color:var(--text-primary,#f1f4fb)}
--@media (pointer:coarse){.ovf-who>summary,.ovf-who--flat,.ovf-tile,.ovf-who-list a{min-height:44px}}
++@media (pointer:coarse){.ovf-who>summary,.ovf-tile,.ovf-who-list a{min-height:44px}}
.ovf-pulse a:hover{color:var(--accent,#60a5fa)}
.ovf-pulse .ovf-sep{color:var(--text-muted,#8b93ad)}
.ovf-pulse-live{display:inline-flex;align-items:center;gap:7px;color:var(--text-primary,#f1f4fb)!important}
@@@ -647,11 +647,13 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
if (o.total > 0) {
const face = (p, size) => (p.avatar ? `<img src="${esc(p.avatar)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async">` : `<i aria-hidden="true">${esc(p.name.charAt(0).toUpperCase())}</i>`);
const parts = [o.members > 0 ? `${fmtN(o.members)} member${o.members === 1 ? '' : 's'}` : '', o.guests > 0 ? `${fmtN(o.guests)} guest${o.guests === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ');
-- const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n"><b>${fmtN(o.total)}</b> online in the last 30 min</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}${parts ? `<span class="ovf-who-sub">${parts}</span>` : ''}`;
++ // Shorthand (the owner, 2026-11-10: "Make it like shorthand '3 online'. If you put your mouse over it, it says
++ // 'last 30 minutes'"): the count and the faces. When, and how many are members and guests, are the tooltip's
++ // and the first line of what a press opens (a finger never sees a tooltip).
++ const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n" data-ov-tip="In the last 30 minutes"${parts ? ` data-ov-tip-sub="${esc(parts)}"` : ''}><b>${fmtN(o.total)}</b> online</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}`;
++ const when = `<p class="ovf-who-when">In the last 30 minutes${parts ? ` · ${parts}` : ''}</p>`;
const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join('');
-- who = list
-- ? `<details class="ovf-who"><summary>${head}</summary><ul class="ovf-who-list">${list}</ul>${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`
-- : `<div class="ovf-who ovf-who--flat">${head}</div>`;
++ who = `<details class="ovf-who"><summary>${head}</summary>${when}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`;
}
// Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this
// site's own items, and the two facts the strip always had.
ddiff --git a/navbar.js b/navbar.js
iindex cb07402..a1abf4a 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -594,6 +594,18 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
++
++ // ov-tip.js loads at the first hover over a tooltip's owner.
++ function tipWait() {
++ if (root.__ovTip || !document.addEventListener) return;
++ root.__ovTip = 1;
++ const tip = (e) => {
++ if (!e.target.closest('[data-ov-tip]')) return;
++ document.removeEventListener('pointerover', tip);
++ if (!root.OVTip) { const sc = document.createElement('script'); sc.src = siblingV('ov-tip.js'); sc.async = true; document.head.appendChild(sc); }
++ };
++ document.addEventListener('pointerover', tip, { passive: true });
++ }
// ov-glyphs.js, when a page shows a Font Awesome-style icon: now, or the first time one appears. One watch per page,
// made here and not in the bar's render, so it holds nothing of a bar that a page move (Boost) re-renders. A
// mutation only schedules one look 250 ms later, and the watch ends once the glyphs are loading.
@@@ -2128,8 +2140,7 @@
hookSearch(nav);
hookSwitcher(nav); hookPages(nav); hookSignIn(nav); hookFit(nav);
// The tooltip's file is fetched when a pointer first comes over the bar, not with the page.
-- const tip = () => { nav.removeEventListener('pointerover', tip); if (!root.OVTip && !document.getElementById('ov-tip-loader')) { try { const sc = document.createElement('script'); sc.id = 'ov-tip-loader'; sc.src = siblingV('ov-tip.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ } } };
-- if (typeof nav.addEventListener === 'function') nav.addEventListener('pointerover', tip, { passive: true });
++ tipWait();
bindChips(nav);
bindChipsAll(nav);
queueIcons();
ddiff --git a/package-lock.json b/package-lock.json
iindex 0bc180a..34da7ea 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.196.0",
++ "version": "4.197.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.196.0",
++ "version": "4.197.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 02da899..117675d 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.196.0",
++ "version": "4.197.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/footer-online.test.js b/test/footer-online.test.js
iindex 506c96a..98983a6 100644
---- a/test/footer-online.test.js
++++ b/test/footer-online.test.js
@@@ -53,7 +53,9 @@ const html = f.onlineHTML(clean);
assert.ok(html.includes('<a class="ovf-tile is-live" href="https://openvibe.live/"><span class="ovf-dot" aria-hidden="true"></span><b>2</b><span>live now</span><small>41 watching</small></a>'), 'live is one small tile, with its dot and its way in');
assert.ok(html.indexOf('ovf-who') < html.indexOf('is-live'), 'who is online comes first; live is among the tiles');
assert.ok(html.includes('<b>56</b><span>sites, one account</span>') && html.includes('<b>81k</b><span>page views this week</span>'), 'the old facts are kept, as tiles');
--assert.ok(html.includes('<details class="ovf-who">') && html.includes('<b>10</b> online in the last 30 min') && html.includes('3 members · 7 guests'), 'who is online opens the list; guests are a count');
++assert.ok(html.includes('<details class="ovf-who">') && html.includes('<b>10</b> online</span>') && html.includes('data-ov-tip="In the last 30 minutes" data-ov-tip-sub="3 members · 7 guests"'), 'shorthand: the count, with when and who in its tooltip');
++assert.ok(html.includes('<p class="ovf-who-when">In the last 30 minutes · 3 members · 7 guests</p>'), 'a press opens the same words (a finger never sees a tooltip)');
++assert.ok(!/online in the last 30 min/.test(html), 'the strip itself stays short');
assert.ok(/class="ovf-who-faces" aria-hidden="true"><img src="https:\/\/openvibe\.network\/avatar\/ann\?s=64"[^>]*><i aria-hidden="true">B<\/i>/.test(html), 'a few faces beside the count: a picture, or a first letter where there is none');
assert.ok(html.includes('href="https://openvibe.network/@ann"') && html.includes('https://openvibe.network/avatar/ann?s=64') && html.includes('<small>Live</small>'), 'a named member links to their profile with their picture and site');
assert.ok(html.includes('and 2 more'), 'the rest of the list is a count');
@@@ -70,7 +72,10 @@ assert.ok(!f.onlineHTML(f.cleanOnline({ ...read, live: { channels: 0, watching:
assert.ok(f.pulseHTML({ live_now: 0, watching: 0, sites: 40, views_7d: 1500 }).includes('Nobody live right now · go live'), 'the old line is the fallback');
assert.ok(!f.onlineHTML(f.cleanOnline({ ...read, members: 0, guests: 0, total: 0, people: [] })).includes('online in the last'), 'nobody online: no list');
assert.ok(!f.onlineHTML(f.cleanOnline({ ...read, counts: [], items: [] })).includes('ovf-online-actions'), 'no counts and no items: only the old facts');
--assert.ok(f.onlineHTML(f.cleanOnline({ ...read, people: [], members: 0, total: 7 })).includes('<div class="ovf-who ovf-who--flat">'), 'guests only: a line, with nothing to open');
++{
++ const guests = f.onlineHTML(f.cleanOnline({ ...read, people: [], members: 0, total: 7 }));
++ assert.ok(guests.includes('<details class="ovf-who">') && guests.includes('<p class="ovf-who-when">In the last 30 minutes') && !guests.includes('ovf-who-list'), 'guests only: the count opens when, with no list of names');
++}
// The slot stays in the footer; the strip is read from /api/frame/online, with the old line as the fallback.
assert.ok(f.buildHTML({ service: 'live', variant: 'full' }).includes('class="ovf-pulse" data-ovf-pulse'), 'the strip slot');
ddiff --git a/test/menu-places-chrome.test.js b/test/menu-places-chrome.test.js
iindex 823c4bc..0cdf4a8 100644
---- a/test/menu-places-chrome.test.js
++++ b/test/menu-places-chrome.test.js
@@@ -59,7 +59,9 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
if (width > 900) {
await page.evaluate(OPEN(true).replace("frame: {},", `frame: {}, anchor: { top: 52, left: ${width - 150}, cx: ${width - 130}, cy: 26 },`));
assert.ok(await until(`!!document.querySelector('.ovs.is-open.ovs-anchored .ovs-panel')`), 'opened from a button');
-- await wait(420);
++ // Measured once the sheet has stopped growing: a fixed wait read it mid-motion on a busy machine (2026-11-10).
++ await wait(120);
++ await until(`document.querySelector('.ovs-panel').getAnimations().every((a) => a.playState !== 'running')`);
const o = await page.evaluate(`(() => { const p = document.querySelector('.ovs-panel'), r = p.getBoundingClientRect(); return { origin: parseFloat(getComputedStyle(p).transformOrigin), left: r.left, right: r.right }; })()`);
assert.ok(Math.abs(o.origin - (width - 130 - o.left)) <= 2, `the sheet's origin is the pressed button's centre (${JSON.stringify(o)})`);
assert.ok(o.right <= width && o.left >= 0, `and the sheet is wholly on the screen (${JSON.stringify(o)})`);