openvibers/openvibe.shared Commits

4.15.0: the footer adapts to the page (claims), opens up from compact (crawlable), and shows the network right now (pulse line) (#124)

OP OpenVibers committed 17 days ago · 17e522da92 · 5 files, +244 −18

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e8a5c09..755cfc3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,25 @@ 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.15.0 — 2026-10-27
++
++- **The footer adapts to what the page is doing, opens up from compact, and shows the network right now** (`footer.js`;
++ the owner, 2026-10-27: "you can use compact footer on the live streamer channel when they are live only … but it
++ should be full mode when not live. make sure thats something the shared footer system can handle … in a modular way
++ … more alive and has some stats … allow user to expand to full even on pages that use compact … ensure maximized SEO").
++ - `OpenVibeFooter.claim('compact' | 'full', key)` → `release()`: a part of the page asks for a variant while it needs
++ it (Live's channel while its stream plays). Compact wins over full, any claim over the page's own; releasing brings
++ back what is left. `setVariant` stays the page's own (an SPA route's); `OpenVibeFooter.variant` is what shows.
++ - The compact footer carries the whole footer in a "More from OpenVibe" disclosure (a `<details>`): opening it needs
++ no script, and its links are in the page, server-rendered too, so a player or channel page links the network the
++ way a home page does (crawlers follow them; nothing is hidden by CSS).
++ - The pulse line, at the top of the full footer (and the opened compact one): streams live now (a pulsing dot),
++ people watching, the open sites and last week's page views, from OpenVibe.Network's `/api/frame/pulse`
++ (OpenVibe.Network#345). It is read when the footer scrolls into view and every minute while it stays there;
++ `pulse: false` leaves it out. Nobody live: "Nobody live right now · go live".
++ - The network column lists eight sites, not six.
++ `test/footer-alive.test.js`.
++
## 4.14.0 — 2026-10-26
- **The navbar's brand is big enough for a finger** (`navbar.js`). On touch screens the wordmark's links were 22 px tall
ddiff --git a/footer.js b/footer.js
iindex ed1e05e..a4a14b2 100644
---- a/footer.js
++++ b/footer.js
@@@ -1,8 +1,12 @@
/**
* openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js.
*
-- * OpenVibeFooter.init({ service, variant, links, apiBase, mount })
-- * OpenVibeFooter.setVariant('full' | 'compact') switch at runtime (SPA route changes)
++ * OpenVibeFooter.init({ service, variant, links, apiBase, mount, pulse })
++ * OpenVibeFooter.setVariant('full' | 'compact') the page's own variant (SPA route changes)
++ * OpenVibeFooter.claim('compact' | 'full', key) → release(): a part of the page asks for a variant while it needs
++ * it (Live's channel while its stream is live: compact; offline:
++ * the page's own full). Compact wins over full, any claim over the
++ * page's own; releasing a claim brings back what is left.
* OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR
*
* Why this exists: every property was hand-rolling its own footer, so the network links, the
@@@ -14,7 +18,13 @@
* Variants:
* full the multi-column footer, for home and browse pages
* compact one quiet line, for pages where the footer is not the point — a streamer's
-- * channel, a player, a tool someone is mid-task in
++ * channel, a player, a tool someone is mid-task in. "More from OpenVibe" opens the
++ * whole footer in place (a <details>: no script needed, and every link stays in the
++ * page for crawlers, so a player page links the network as a home page does)
++ *
++ * The pulse line (full, and an opened compact one): what is happening on the network right now —
++ * streams live, people watching, open sites, last week's views — from openvibe.network/api/frame/pulse,
++ * read once the footer scrolls into view and every minute while it stays there; numbers only.
*
* Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
* hub, so the network behaves like one product instead of a ring of separate sites. It reads
@@@ -126,11 +136,20 @@
showAccount: true,
sitemap: '/sitemap.xml',
shipped: true, // the "shipped X ago" line (openvibe-shared/shipped.js)
++ pulse: true, // the network-right-now line (openvibe.network/api/frame/pulse)
updates: null, // this site's update log; default: openvibe.network/updates?site=<host>
};
let _cfg = { ...DEFAULTS };
let _mounted = null;
++ const _claims = new Map(); // key → 'compact' | 'full' (claim())
++ /** The variant shown: a compact claim, else a full one, else the page's own. */
++ function shownVariant(base) {
++ const asked = [..._claims.values()];
++ if (asked.includes('compact')) return 'compact';
++ if (asked.includes('full')) return 'full';
++ return base === 'compact' ? 'compact' : 'full';
++ }
function detectService() {
if (typeof location === 'undefined') return 'network';
@@@ -224,7 +243,7 @@
: NETWORK.filter(n => n.id !== service);
const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline;
const popular = frame ? frame.footer.popular : [];
-- const discover = frame ? frame.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];
++ const discover = frame ? frame.footer.discover.filter(d => !network.slice(0, 8).some(n => n.url === d.url)) : [];
const year = new Date().getFullYear();
if (c.variant === 'compact') {
@@@ -239,8 +258,14 @@
</nav>
${shippedLine(c)}
<span class="ovf-compact-meta">Open source · ${year}</span>
-- </div>`;
++ </div>
++ <details class="ovf-expand"${c._open ? ' open' : ''}><summary>More from OpenVibe</summary>${fullBody(c, { network, popular, discover, brand, tagline, compact: true })}</details>`;
}
++ return fullBody(c, { network, popular, discover, brand, tagline });
++ }
++
++ /** The multi-column footer: the brand, the site's own links, the network, popular tools, legal, the account row and the bar. */
++ function fullBody(c, { network, popular, discover, brand, tagline, compact = false }) {
const cols = (c.links || []).map(group => `
<nav class="ovf-col" aria-label="${esc(group.heading)}">
@@@ -248,7 +273,8 @@
${group.items.map(linkTag).join('')}
</nav>`).join('');
-- return `<div class="ovf-inner">
++ return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">
++ ${c.pulse === false ? '' : '<p class="ovf-pulse" data-ovf-pulse hidden></p>'}
<div class="ovf-cols">
<div class="ovf-col ovf-col--brand">
<a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
@@@ -261,7 +287,7 @@
${cols}
${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">
<h3>Network</h3>
-- ${network.slice(0, 6).map(linkTag).join('')}
++ ${network.slice(0, 8).map(linkTag).join('')}
<a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>
</nav>` : ''}
${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">
@@@ -275,7 +301,7 @@
${reportLink()}
</nav>
</div>
-- <div class="ovf-account" id="ovf-account" hidden></div>
++ ${compact ? '' : `<div class="ovf-account" id="ovf-account" hidden></div>
<div class="ovf-bar">
<span class="ovf-copy">Open source &amp; community driven · built in the open</span>
${shippedLine(c)}
@@@ -283,7 +309,7 @@
${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
</span>
-- </div>
++ </div>`}
</div>`;
}
@@@ -380,6 +406,23 @@
.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}
.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)}
.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}
++.ovf-expand{max-width:1240px;margin:8px auto 0}
++.ovf-expand>summary{list-style:none;cursor:pointer;width:fit-content;margin:0 auto;padding:4px 12px;border-radius:999px;font-size:.76rem;font-weight:600;color:var(--text-muted,#8b93ad);border:1px solid var(--border,rgba(255,255,255,.08));transition:color .15s,border-color .15s}
++.ovf-expand>summary::-webkit-details-marker{display:none}
++.ovf-expand>summary::after{content:"";display:inline-block;width:.42em;height:.42em;margin-left:.5em;border:solid currentColor;border-width:0 1.6px 1.6px 0;transform:translateY(-.15em) rotate(45deg);transition:transform .2s}
++.ovf-expand[open]>summary::after{transform:translateY(.05em) rotate(-135deg)}
++.ovf-expand>summary:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa)}
++.ovf-expand[open]>summary{margin-bottom:clamp(16px,2.5vw,24px)}
++.ovf-inner--expanded{animation:ovfIn .3s ease-out both}
++.ovf-pulse{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin:0 0 clamp(18px,2.5vw,24px);padding:9px 14px;border-radius:12px;font-size:.8rem;color:var(--text-secondary,#b6bdd2);
++ 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: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}
++.ovf-dot{width:8px;height:8px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:ovfLive 1.8s ease-out infinite}
++@keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
++.ovf-pulse-quiet{font-weight:500!important;color:var(--text-muted,#8b93ad)!important}
@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
@media (max-width:560px){
.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
@@@ -389,7 +432,7 @@
.ovf-legal-inline{justify-content:center}
.ovf-account{flex-direction:column;align-items:flex-start}
}
--@media (prefers-reduced-motion:reduce){.ovf-account{animation:none}.ovf-col a:hover,.ovf-social a:hover,.ovf-account-links a:hover{transform:none}}
++@media (prefers-reduced-motion:reduce){.ovf-account,.ovf-pulse,.ovf-inner--expanded,.ovf-dot{animation:none}.ovf-col a:hover,.ovf-social a:hover,.ovf-account-links a:hover{transform:none}}
.ovf{-webkit-tap-highlight-color:transparent}
:where(.ovf) :where(a,button):active{scale:.97}
:where(.ovf) :where(a,button):focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:2px;border-radius:4px}
@@@ -406,9 +449,12 @@
// ── Mount ──────────────────────────────────────────────────
function render() {
if (!_mounted) return;
++ const variant = shownVariant(_cfg.variant);
++ // An opened "More from OpenVibe" stays open through a re-render (a claim, the Frame's first answer).
++ const open = !!(_mounted.querySelector && _mounted.querySelector('details.ovf-expand[open]'));
_mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
-- _mounted.setAttribute('data-variant', _cfg.variant);
-- _mounted.innerHTML = buildHTML(_cfg);
++ _mounted.setAttribute('data-variant', variant);
++ _mounted.innerHTML = buildHTML({ ..._cfg, variant, _open: open });
if (!_mounted._ovReport) {
_mounted._ovReport = true;
const fresh = (e) => { const a = e.target && e.target.closest && e.target.closest('a[data-ov-report]'); if (a) a.href = reportHref(); };
@@@ -416,8 +462,62 @@
_mounted.addEventListener('auxclick', fresh);
}
try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
-- fillAccount(_cfg);
++ fillAccount({ ..._cfg, variant });
if (_cfg.shipped !== false) loadShipped();
++ watchPulse();
++ }
++
++ // ── The pulse: the network right now ───────────────────────
++ // Read once the footer is in view (nobody who never scrolls there costs a request), then every minute while it is.
++ const PULSE_URL = `${NETWORK_URL}/api/frame/pulse`;
++ const PULSE_MS = 60_000;
++ let _pulse = null;
++ let _pulseAt = 0;
++ let _pulseObserver = null;
++ let _pulseTimer = null;
++ const fmtN = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(n));
++ function pulseHTML(p) {
++ const bits = [];
++ if (p.live_now != null) {
++ bits.push(p.live_now > 0
++ ? `<a class="ovf-pulse-live" href="https://openvibe.live/"><span class="ovf-dot" aria-hidden="true"></span>${fmtN(p.live_now)} live now</a>`
++ : '<a class="ovf-pulse-quiet" href="https://openvibe.live/">Nobody live right now · go live</a>');
++ }
++ if (p.watching > 0) bits.push(`<span>${fmtN(p.watching)} watching</span>`);
++ if (p.sites > 0) bits.push(`<a href="${NETWORK_URL}/#network">${fmtN(p.sites)} sites, one account</a>`);
++ if (p.views_7d > 0) bits.push(`<span>${fmtN(p.views_7d)} page views this week</span>`);
++ return bits.join('<span class="ovf-sep" aria-hidden="true">·</span>');
++ }
++ function paintPulse() {
++ if (!_mounted || !_pulse) return;
++ const html = pulseHTML(_pulse);
++ for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) { el.innerHTML = html; el.hidden = !html; }
++ }
++ function readPulse() {
++ if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return;
++ if (_pulse && Date.now() - _pulseAt < PULSE_MS - 1000) { paintPulse(); return; }
++ fetch(PULSE_URL, { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then((p) => {
++ if (!p || typeof p !== 'object') return;
++ const n = (v) => (Number.isFinite(Number(v)) && v !== null ? Math.max(0, Math.floor(Number(v))) : null);
++ _pulse = { live_now: n(p.live_now), watching: n(p.watching), sites: n(p.sites), views_7d: n(p.views_7d) };
++ _pulseAt = Date.now();
++ paintPulse();
++ }).catch(() => { /* a quiet footer is fine */ });
++ }
++ function watchPulse() {
++ if (!_mounted || _cfg.pulse === false || typeof window === 'undefined') return;
++ if (_pulse) paintPulse();
++ if (_pulseObserver) return;
++ if (typeof IntersectionObserver !== 'function') { readPulse(); return; }
++ _pulseObserver = new IntersectionObserver((entries) => {
++ const seen = entries.some(e => e.isIntersecting);
++ clearInterval(_pulseTimer);
++ _pulseTimer = null;
++ if (!seen) return;
++ readPulse();
++ _pulseTimer = setInterval(readPulse, PULSE_MS);
++ }, { rootMargin: '200px 0px' });
++ _pulseObserver.observe(_mounted);
}
// shipped.js mounts every [data-ov-shipped] element when it loads; loaded once, lazily, after the footer.
@@@ -447,11 +547,32 @@
return el;
}
++ /** The page's own variant (an SPA route's); a claim still wins while it holds. */
function setVariant(variant) {
if (variant !== 'full' && variant !== 'compact') return;
if (_cfg.variant === variant) return;
++ const before = shownVariant(_cfg.variant);
_cfg.variant = variant;
-- render();
++ if (shownVariant(variant) !== before) render();
++ }
++
++ /**
++ * A part of the page asks for a variant while it needs it: Live's channel claims 'compact' while its stream plays and
++ * releases it when the stream ends or the route changes, and the page's own variant comes back. Same key twice
++ * replaces. → release()
++ */
++ function claim(variant, key = 'default') {
++ if (variant !== 'full' && variant !== 'compact') return () => {};
++ const k = String(key);
++ const before = shownVariant(_cfg.variant);
++ _claims.set(k, variant);
++ if (shownVariant(_cfg.variant) !== before) render();
++ return function release() {
++ if (_claims.get(k) !== variant) return;
++ const was = shownVariant(_cfg.variant);
++ _claims.delete(k);
++ if (shownVariant(_cfg.variant) !== was) render();
++ };
}
/**
@@@ -465,5 +586,5 @@
return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;
}
-- return { init, ssr, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
++ return { init, ssr, setVariant, claim, buildHTML, render, CSS, NETWORK, LEGAL, detectService, pulseHTML, get config() { return { ..._cfg }; }, get variant() { return shownVariant(_cfg.variant); } };
});
ddiff --git a/package-lock.json b/package-lock.json
iindex ada7b80..1ef479b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.14.0",
++ "version": "4.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.14.0",
++ "version": "4.15.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 7fac5bd..adcf747 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.14.0",
++ "version": "4.15.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-alive.test.js b/test/footer-alive.test.js
nnew file mode 100644
iindex 0000000..797bcd3
---- /dev/null
++++ b/test/footer-alive.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++// The footer, 2026-10-27 (owner: "you can use compact footer on the live streamer channel when they are live only … but
++// it should be full mode when not live. make sure thats something the shared footer system can handle … in a modular
++// way … more alive and has some stats … allow user to expand to full even on pages that use compact … ensure maximized
++// SEO"):
++// - claim(variant, key) → release(): a part of the page asks for a variant while it needs it; compact wins over full,
++// any claim over the page's own; releasing brings back what is left; setVariant stays the page's own.
++// - The compact footer carries the whole footer in a <details> ("More from OpenVibe"): no script needed, and its links
++// are in the page for crawlers (a player page links the network as a home page does). No second shipped line.
++// - The pulse line: a slot in the full footer (and the opened compact one), painted from /api/frame/pulse.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8');
++function load(host = 'openvibe.live') {
++ const ctx = vm.createContext({ console, URLSearchParams });
++ ctx.globalThis = ctx; ctx.window = ctx;
++ ctx.location = { protocol: 'https:', hostname: host, origin: `https://${host}`, pathname: '/@kim', href: `https://${host}/@kim` };
++ ctx.localStorage = { getItem: () => null, setItem() {} };
++ vm.runInContext(SRC, ctx);
++ return ctx.OpenVibeFooter;
++}
++
++// ── claims ──
++{
++ const f = load();
++ assert.strictEqual(f.variant, 'full', 'the page\'s own: full');
++ const releaseLive = f.claim('compact', 'channel-live');
++ assert.strictEqual(f.variant, 'compact', 'a live stream asks for compact');
++ const releaseOther = f.claim('full', 'something');
++ assert.strictEqual(f.variant, 'compact', 'compact wins over full');
++ f.setVariant('full');
++ assert.strictEqual(f.variant, 'compact', 'setVariant is the page\'s own; a claim still holds');
++ releaseLive();
++ assert.strictEqual(f.variant, 'full', 'the stream ended: back to full');
++ releaseLive();
++ assert.strictEqual(f.variant, 'full', 'releasing twice does nothing');
++ releaseOther();
++ f.setVariant('compact');
++ assert.strictEqual(f.variant, 'compact', 'a compact page');
++ const r = f.claim('full', 'k');
++ assert.strictEqual(f.variant, 'full', 'a claim beats the page\'s own');
++ f.claim('compact', 'k');
++ assert.strictEqual(f.variant, 'compact', 'the same key replaces its claim');
++ r();
++ assert.strictEqual(f.variant, 'compact', 'an old release does not drop the newer claim of its key');
++ assert.strictEqual(typeof f.claim('tiny'), 'function', 'an unknown variant: a no-op release');
++}
++
++// ── the compact footer opens into the whole one ──
++{
++ const f = load();
++ const compact = f.buildHTML({ service: 'live', variant: 'compact' });
++ const full = f.buildHTML({ service: 'live', variant: 'full' });
++ assert.ok(/<details class="ovf-expand"><summary>More from OpenVibe<\/summary>/.test(compact), 'a "More from OpenVibe" disclosure');
++ const inside = compact.slice(compact.indexOf('<details'));
++ assert.ok(inside.includes('The OpenVibe network') && inside.includes('Legal'), 'with the network and legal columns');
++ const links = (h) => (h.match(/href="https:\/\/[a-z.]*openvibe\.[a-z]+[^"]*"/g) || []).length;
++ assert.ok(links(compact) >= links(full) - 6, `a compact page links the network nearly as a full one does (${links(compact)} vs ${links(full)})`);
++ assert.strictEqual((compact.match(/data-ov-shipped/g) || []).length, 1, 'one shipped line');
++ assert.ok(!inside.includes('id="ovf-account"'), 'no second account row');
++ assert.ok(f.buildHTML({ service: 'live', variant: 'compact', _open: true }).includes('<details class="ovf-expand" open>'), 'an opened one stays open through a re-render');
++ assert.ok(full.includes('data-ovf-pulse') && inside.includes('data-ovf-pulse'), 'the pulse slot');
++ assert.ok(!f.buildHTML({ service: 'live', variant: 'full', pulse: false }).includes('data-ovf-pulse'), 'pulse: false leaves it out');
++ const net = full.slice(full.indexOf('aria-label="The OpenVibe network"'));
++ assert.ok((net.slice(0, net.indexOf('</nav>')).match(/<a /g) || []).length >= 9, 'eight sites and "Everything else"');
++ const ssr = require('../footer.js').ssr({ service: 'live', variant: 'compact' });
++ assert.ok(ssr.includes('data-variant="compact"') && ssr.includes('More from OpenVibe'), 'server-rendered too, so crawlers and no-script visitors get it');
++}
++
++// ── the pulse line ──
++{
++ const f = load();
++ const html = f.pulseHTML({ live_now: 3, watching: 41, sites: 40, views_7d: 51234 });
++ assert.ok(html.includes('<span class="ovf-dot" aria-hidden="true"></span>3 live now') && html.includes('href="https://openvibe.live/"'));
++ assert.ok(html.includes('41 watching') && html.includes('40 sites, one account') && html.includes('51k page views this week'));
++ assert.ok(f.pulseHTML({ live_now: 0, watching: 0, sites: 40, views_7d: 1500 }).includes('Nobody live right now · go live'), 'quiet, with a way in');
++ assert.ok(f.pulseHTML({ live_now: 0, sites: 40, views_7d: 1500 }).includes('1.5k page views'));
++ assert.strictEqual(f.pulseHTML({ live_now: null, watching: null, sites: null, views_7d: null }), '', 'nothing known: nothing shown');
++ assert.ok(SRC.includes("new IntersectionObserver(") && SRC.includes('setInterval(readPulse, PULSE_MS)'), 'read when the footer is in view, every minute while it is');
++ assert.ok(SRC.includes('.ovf-dot{') && SRC.includes('prefers-reduced-motion:reduce){.ovf-account,.ovf-pulse,.ovf-inner--expanded,.ovf-dot{animation:none}'), 'the live dot pulses, never with reduced motion');
++}
++
++console.log('footer: claims, the compact footer opening into the whole one, the pulse line');