openvibers/openvibe.shared Commits
4.197.0: the footer says "3 online", and the tooltip part works anywhere (#426)
Changes
@@@ -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@@@ -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 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.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)",@@@ -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;opacityif (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.@@@ -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();@@@ -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",@@@ -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": {@@@ -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');@@@ -59,7 +59,9 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).maif (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)})`);
@@ -1,5 +1,21 @@
@@ -1,5 +1,21 @@
# Changelog
# 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
## 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
**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
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**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
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.196.0",
+ "version": "4.197.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)",
@@ -452,7 +452,7 @@
@@ -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}
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-pulse a{color:inherit;text-decoration:none;font-weight:600}
.ovf-now{flex:1 1 100%;min-width:0;display:grid;gap:8px}
.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{cursor:pointer;border-radius:10px}
.ovf-who>summary::-webkit-details-marker{display:none}
.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}
.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 @@
@@ -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}
.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)}}
@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-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{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-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))}
.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)}
@@ -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 .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-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)}
.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 a:hover{color:var(--accent,#60a5fa)}
.ovf-pulse .ovf-sep{color:var(--text-muted,#8b93ad)}
.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}
.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
@@ -647,11 +647,13 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
if (o.total > 0) {
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 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 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('');
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
// 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.
// site's own items, and the two facts the strip always had.
@@ -594,6 +594,18 @@
@@ -594,6 +594,18 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* 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]}` : ''}`;
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,
// 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
// 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.
// mutation only schedules one look 250 ms later, and the watch ends once the glyphs are loading.
@@ -2128,8 +2140,7 @@
@@ -2128,8 +2140,7 @@
hookSearch(nav);
hookSearch(nav);
hookSwitcher(nav); hookPages(nav); hookSignIn(nav); hookFit(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.
// 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);
bindChips(nav);
bindChipsAll(nav);
bindChipsAll(nav);
queueIcons();
queueIcons();
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.196.0",
+ "version": "4.197.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.196.0",
+ "version": "4.197.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -53,7 +53,9 @@ const html = f.onlineHTML(clean);
@@ -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.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.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('<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(/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('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');
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:
@@ -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.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, 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, 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.
// 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');
assert.ok(f.buildHTML({ service: 'live', variant: 'full' }).includes('class="ovf-pulse" data-ovf-pulse'), 'the strip slot');
@@ -59,7 +59,9 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -59,7 +59,9 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
if (width > 900) {
if (width > 900) {
await page.evaluate(OPEN(true).replace("frame: {},", `frame: {}, anchor: { top: 52, left: ${width - 150}, cx: ${width - 130}, cy: 26 },`));
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');
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 }; })()`);
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(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)})`);
assert.ok(o.right <= width && o.left >= 0, `and the sheet is wholly on the screen (${JSON.stringify(o)})`);