openvibers/openvibe.shared Commits

4.10.0: 2fat's name effect, Report a problem in every footer, status text twins, getStats in 9 scans (#99)

OP OpenVibers committed 20 days ago · c3758c3119 · 14 files, +251 −57

* 4.10.0: items.css draws 2fat Bass Drop (name-fx-twofat) and its particles

* Shared's own status text uses the -text twins (showcase yes cells, island action buttons)

* analytics/pg getStats reads the raw range 9 times instead of 17 (30 days on Live: 8-12 s to 2.8 s), same numbers

* Report a problem in every site's footer: a Help ticket with the site and page (origin + path) filled in

* analytics/pg: botDays keeps bot rows shorter than people's (default unchanged)

* STATUS and the README's status line say 4.10.0 (they said 4.8.0 and 4.7.0; Contracts' docs-currency check)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e7deb74..8bdefff 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,34 @@ 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.10.0 — 2026-10-24
++
++- **items.css draws `name-fx-twofat`, "2fat Bass Drop"**, the one-of-a-kind name effect the owner asked for 2fat on
++ 2026-10-23: the name thumps on the beat in club lights, with an equalizer before it and a speaker after it. Its
++ particles (`px-twofat`) are notes, a CD and a tape in club pink. Like Josh Vegas, the Calm setting
++ (`data-ov-motion=reduced`) and the system's reduced motion stop it, so it shows the same on openvibe.chat, Network's
++ pages and every site drawing items as on Live (Live#318, OpenVibe.Inventory#71).
++- **Shared's own status text uses the 4.9.0 twins**: the showcase's "yes" cells and the island's action buttons
++ (`--ovis-t`: `--success-text`/`--danger-text` by state, the accent otherwise; the status colour stays their fill and
++ tint). Shared now writes no status colour as text without its twin (H4's last uses; the 23 sites moved on 10-22/23).
++- **"Report a problem" in every site's footer** (the Legal column, and the compact line). It opens a ticket on
++ openvibe.help with the site and the page filled in (OpenVibe.Help#81):
++ `https://openvibe.help/tickets/new?site=<host without www.>&page=<origin + path>`.
++ - The page leaves out the query and the fragment, where a sign-in code or a token can ride.
++ - A server-rendered footer links the plain form, and a click fills in the page at that moment, so a single-page
++ app's current route is the one reported.
++- **`analytics/pg` keeps bot rows shorter when asked**: `pruneRawEventsPg(db, { days, botDays })` and the tracker's
++ `retention: { days, botDays }` delete `is_bot` rows after `botDays` (default `days`, so nothing changes until a
++ service sets it). Bot rows are in the hourly and daily rollups once their day closes, and were half or more of the raw
++ table: Live 1.49M of 2.47M rows (it prunes them at 7 days itself since 10-22), Network 1.22M of 2.46M (991 MB).
++- **`analytics/pg` `getStats` reads the raw range 9 times instead of 17** (plan J3). On Live's production data
++ (1.3M rows), 30 days took 8 to 12 s and now 2.8 s; 7 days 3.1 s → 1.5 s. Related reads share one grouped scan,
++ split in JS: status codes and bot types; device, browser, OS and country; the busiest API calls and the slowest paths;
++ and requests, the signed-in split and sessions. Each distinct count per group (visitors per page, per hour of the day,
++ per day signed in or not) groups by session first instead of sorting for `COUNT(DISTINCT)`. The numbers and response
++ shapes are unchanged: the old and new reads matched on production inside one snapshot, and
++ `test/analytics-pg.test.js` checks each list on known rows.
++
## 4.9.0 — 2026-10-23
- **Status colours that read as text** (plan 2026-10-22, track H4). After 4.8.0, axe in the light themes still failed
ddiff --git a/README.md b/README.md
iindex 9773dd3..a5f6fc7 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.7.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.10.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 df78562..dd6cd91 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.8.0",
++ "version": "4.10.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/analytics/pg.js b/analytics/pg.js
iindex cf0ba1b..28c2d6a 100644
---- a/analytics/pg.js
++++ b/analytics/pg.js
@@@ -90,7 +90,7 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
/**
* @param {object} db openvibe-sdk/db handle (the service's own database, analyticsSchema() migrated)
* @param {string} service
-- * @param {object} [opts] paramPrefixes, pathRules, retention ({ days = 30, intervalMs,
++ * @param {object} [opts] paramPrefixes, pathRules, retention ({ days = 30, botDays = days, intervalMs,
* initialDelayMs } or false), timers, now
*/
constructor(db, service, opts = {}) {
@@@ -108,7 +108,8 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
if (opts.retention !== false) {
const r = opts.retention && typeof opts.retention === 'object' ? opts.retention : {};
const days = r.days || 30;
-- const run = () => { pruneRawEventsPg(db, { days, now: this._now }).catch((err) => console.error(`[Analytics:${service}] prune error:`, err.message)); };
++ const botDays = r.botDays || days;
++ const run = () => { pruneRawEventsPg(db, { days, botDays, now: this._now }).catch((err) => console.error(`[Analytics:${service}] prune error:`, err.message)); };
once(run, r.initialDelayMs == null ? 5 * 60e3 : r.initialDelayMs);
every(run, r.intervalMs || DAY);
}
@@@ -319,22 +320,33 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
FROM analytics_daily WHERE service = $1 AND date >= $2`, [svc, dayOf(nowMs - rangeMs)]);
const realtime = await get(`SELECT COUNT(*) AS requests, COUNT(DISTINCT session_id) AS visitors, COUNT(*) FILTER (WHERE is_bot = 1) AS bots
FROM analytics_events WHERE service = $1 AND created_at >= $2`, [svc, sqlTime(nowMs - 300000)]);
-- const topPages = await all(`SELECT path, COUNT(*) AS hits, COUNT(DISTINCT session_id) AS visitors FROM analytics_events
-- WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY path ORDER BY hits DESC LIMIT 20`, human);
++ // Each raw read below scans the range of analytics_events, so related ones share a scan (9 instead of 17: Live's
++ // 30 days of 1.3M rows took several seconds, plan J3). A grouped read is split here, and a distinct count per
++ // group is two GROUP BYs (each session once per group, then counted), which hash instead of sorting. The
++ // numbers and shapes are the ones the separate reads gave.
++ const desc = (key) => (x, y) => y[key] - x[key];
++ const tally = (rows, col, n, out = 'cnt') => {
++ const m = new Map();
++ for (const r of rows) m.set(r[col], (m.get(r[col]) || 0) + r[n]);
++ return [...m].map(([k, v]) => ({ [col]: k, [out]: v })).sort(desc(out));
++ };
++ const topPages = await all(`SELECT path, SUM(c)::bigint AS hits, COUNT(session_id) AS visitors FROM (
++ SELECT path, session_id, COUNT(*) AS c FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0
++ GROUP BY path, session_id) s
++ GROUP BY path ORDER BY hits DESC LIMIT 20`, human);
const topReferers = await all(`SELECT referer, COUNT(*) AS hits FROM analytics_events WHERE service = $1 AND created_at >= $2
AND referer IS NOT NULL AND referer != '' AND is_bot = 0 GROUP BY referer ORDER BY hits DESC LIMIT 15`, human);
-- const botBreakdown = await all(`SELECT bot_type, COUNT(*) AS hits FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 1
-- GROUP BY bot_type ORDER BY hits DESC LIMIT 15`, human);
-- const statusCodes = await all(`SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN '2xx' WHEN status_code >= 300 AND status_code < 400 THEN '3xx'
++ const statusRows = await all(`SELECT is_bot, bot_type, CASE WHEN status_code >= 200 AND status_code < 300 THEN '2xx' WHEN status_code >= 300 AND status_code < 400 THEN '3xx'
WHEN status_code >= 400 AND status_code < 500 THEN '4xx' WHEN status_code >= 500 THEN '5xx' ELSE 'other' END AS group_code, COUNT(*) AS cnt
-- FROM analytics_events WHERE service = $1 AND created_at >= $2 GROUP BY group_code ORDER BY cnt DESC`, human);
-- const breakdown = (col) => all(`SELECT ${col}, COUNT(*) AS cnt FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0
-- GROUP BY ${col} ORDER BY cnt DESC`, human);
-- const deviceBreakdown = await breakdown('device_type');
-- const browserBreakdown = await breakdown('browser');
-- const osBreakdown = await breakdown('os');
-- const countryBreakdown = await all(`SELECT country, COUNT(*) AS cnt FROM analytics_events WHERE service = $1 AND created_at >= $2
-- AND country IS NOT NULL AND is_bot = 0 GROUP BY country ORDER BY cnt DESC LIMIT 20`, human);
++ FROM analytics_events WHERE service = $1 AND created_at >= $2 GROUP BY 1, 2, 3`, human);
++ const botBreakdown = tally(statusRows.filter((r) => r.is_bot === 1), 'bot_type', 'cnt', 'hits').slice(0, 15);
++ const statusCodes = tally(statusRows, 'group_code', 'cnt');
++ const dims = await all(`SELECT device_type, browser, os, country, COUNT(*) AS cnt FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0
++ GROUP BY 1, 2, 3, 4`, human);
++ const deviceBreakdown = tally(dims, 'device_type', 'cnt');
++ const browserBreakdown = tally(dims, 'browser', 'cnt');
++ const osBreakdown = tally(dims, 'os', 'cnt');
++ const countryBreakdown = tally(dims.filter((r) => r.country != null), 'country', 'cnt').slice(0, 20);
const responsePercentiles = await safe(async () => {
const r = await get(`SELECT percentile_disc(0.5) WITHIN GROUP (ORDER BY response_time_ms) AS p50, percentile_disc(0.9) WITHIN GROUP (ORDER BY response_time_ms) AS p90,
percentile_disc(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95, percentile_disc(0.99) WITHIN GROUP (ORDER BY response_time_ms) AS p99,
@@@ -342,36 +354,46 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
FROM analytics_events WHERE service = $1 AND created_at >= $2 AND response_time_ms IS NOT NULL AND is_bot = 0`, human);
return r && r.max != null ? r : {};
}, {});
-- const bandwidth = await safe(async () => {
-- const bw = await get(`SELECT COUNT(*) AS total_requests, COUNT(*) FILTER (WHERE event_type = 'pageview') AS page_requests,
-- COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_requests, SUM(CASE WHEN event_type = 'pageview' THEN 50000 ELSE 2000 END)::bigint AS estimated_bytes
-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0`, human);
-- return bw && { total_requests: bw.total_requests || 0, page_requests: bw.page_requests || 0, api_requests: bw.api_requests || 0, estimated_bytes: bw.estimated_bytes || 0 };
-- }, {});
-- const authBreakdown = await safe(() => get(`SELECT COUNT(*) FILTER (WHERE authenticated = 1) AS authenticated, COUNT(*) FILTER (WHERE authenticated = 0) AS anonymous,
-- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS unique_authenticated_users, COUNT(DISTINCT session_id) FILTER (WHERE session_id IS NOT NULL) AS total_sessions
-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0`, human), {});
++ // Requests, signed-in share and sessions: one scan (a session can be anonymous and then signed in, so the
++ // session total counts distinct ids over the per-session rows).
++ const totals = await safe(() => get(`SELECT SUM(c)::bigint AS total_requests, SUM(pv)::bigint AS page_requests, SUM(api)::bigint AS api_requests,
++ SUM(pv * 50000 + (c - pv) * 2000)::bigint AS estimated_bytes,
++ COALESCE(SUM(c) FILTER (WHERE a = 1), 0)::bigint AS authenticated, COALESCE(SUM(c) FILTER (WHERE a = 0), 0)::bigint AS anonymous,
++ COUNT(session_id) FILTER (WHERE a = 1) AS unique_authenticated_users, COUNT(DISTINCT session_id) AS total_sessions
++ FROM (SELECT authenticated AS a, session_id, COUNT(*) AS c, COUNT(*) FILTER (WHERE event_type = 'pageview') AS pv,
++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api
++ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY 1, 2) s`, human), null);
++ const bandwidth = totals ? { total_requests: totals.total_requests || 0, page_requests: totals.page_requests || 0, api_requests: totals.api_requests || 0,
++ estimated_bytes: totals.estimated_bytes || 0 } : {};
++ const authBreakdown = totals ? { authenticated: totals.authenticated, anonymous: totals.anonymous,
++ unique_authenticated_users: totals.unique_authenticated_users, total_sessions: totals.total_sessions } : {};
++ const sessionCount = totals ? totals.total_sessions || 0 : 0;
const errorTrend = await safe(() => (isSubDay
? all(`SELECT ${bucketExpr} AS bucket, COUNT(*) AS total, COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
COUNT(*) FILTER (WHERE status_code >= 500) AS server_errors
FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY bucket ORDER BY bucket ASC`, human)
: all('SELECT hour AS bucket, (pageviews + api_calls) AS total, error_count AS errors FROM analytics_hourly WHERE service = $1 AND hour >= $2 ORDER BY hour ASC', [svc, hourlyCutoff])), []);
-- const peakHours = await safe(() => all(`SELECT substr(created_at, 12, 2)::int AS hour_of_day, COUNT(*) AS hits, COUNT(DISTINCT session_id) AS visitors,
-- CAST(AVG(response_time_ms) AS integer) AS avg_ms
-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY hour_of_day ORDER BY hour_of_day ASC`, human), []);
-- const topApiEndpoints = await safe(() => all(`SELECT path, method, COUNT(*) AS hits, CAST(AVG(response_time_ms) AS integer) AS avg_ms,
-- COUNT(*) FILTER (WHERE status_code >= 400) AS errors
-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND event_type = 'api_call' AND is_bot = 0 GROUP BY path, method ORDER BY hits DESC LIMIT 20`, human), []);
-- const sessionCount = await safe(() => this.db.value(`SELECT COUNT(DISTINCT session_id) FROM analytics_events WHERE service = $1 AND created_at >= $2
-- AND session_id IS NOT NULL AND is_bot = 0`, human), 0);
++ const peakHours = await safe(() => all(`SELECT h AS hour_of_day, SUM(c)::bigint AS hits, COUNT(session_id) AS visitors,
++ CAST(SUM(rt)::numeric / NULLIF(SUM(rn), 0) AS integer) AS avg_ms
++ FROM (SELECT substr(created_at, 12, 2)::int AS h, session_id, COUNT(*) AS c, SUM(response_time_ms) AS rt, COUNT(response_time_ms) AS rn
++ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY 1, 2) s
++ GROUP BY h ORDER BY h ASC`, human), []);
++ // Endpoints: the busiest API calls and the slowest paths (3+ timed requests) from one grouped read.
++ const endpoints = await safe(() => all(`SELECT path, method, COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_hits,
++ CAST(AVG(response_time_ms) FILTER (WHERE event_type = 'api_call') AS integer) AS api_avg_ms,
++ COUNT(*) FILTER (WHERE event_type = 'api_call' AND status_code >= 400) AS api_errors,
++ COUNT(response_time_ms) AS timed, CAST(AVG(response_time_ms) AS integer) AS avg_ms, MAX(response_time_ms) AS max_ms
++ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY path, method`, human), []);
++ const topApiEndpoints = endpoints.filter((r) => r.api_hits > 0)
++ .map((r) => ({ path: r.path, method: r.method, hits: r.api_hits, avg_ms: r.api_avg_ms, errors: r.api_errors })).sort(desc('hits')).slice(0, 20);
const visitorTypes = { new_visitors: null, returning_visitors: null, not_collected: 'ADR-021' };
-- const slowestEndpoints = await safe(() => all(`SELECT path, method, COUNT(*) AS hits, CAST(AVG(response_time_ms) AS integer) AS avg_ms, MAX(response_time_ms) AS max_ms
-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND response_time_ms IS NOT NULL AND is_bot = 0
-- GROUP BY path, method HAVING COUNT(*) >= 3 ORDER BY avg_ms DESC LIMIT 10`, human), []);
-- const authTrend = await safe(() => all(`SELECT ${isSubDay ? bucketExpr : 'substr(created_at, 1, 10)'} AS bucket,
-- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS auth_visitors, COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 0) AS anon_visitors,
-- COUNT(*) FILTER (WHERE authenticated = 1) AS auth_hits, COUNT(*) FILTER (WHERE authenticated = 0) AS anon_hits
-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY bucket ORDER BY bucket ASC`, human), []);
++ const slowestEndpoints = endpoints.filter((r) => r.timed >= 3)
++ .map((r) => ({ path: r.path, method: r.method, hits: r.timed, avg_ms: r.avg_ms, max_ms: r.max_ms })).sort(desc('avg_ms')).slice(0, 10);
++ const authTrend = await safe(() => all(`SELECT b AS bucket, COUNT(session_id) FILTER (WHERE a = 1) AS auth_visitors, COUNT(session_id) FILTER (WHERE a = 0) AS anon_visitors,
++ COALESCE(SUM(c) FILTER (WHERE a = 1), 0)::bigint AS auth_hits, COALESCE(SUM(c) FILTER (WHERE a = 0), 0)::bigint AS anon_hits
++ FROM (SELECT ${isSubDay ? bucketExpr : 'substr(created_at, 1, 10)'} AS b, authenticated AS a, session_id, COUNT(*) AS c
++ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY 1, 2, 3) s
++ GROUP BY b ORDER BY b ASC`, human), []);
return {
service: svc, period_days: hours ? hours / 24 : days, period_hours: hours || null, summary: summary || {}, realtime: realtime || {},
daily, hourly, timeBuckets, topPages, topReferers, botBreakdown, statusCodes, deviceBreakdown, browserBreakdown, osBreakdown,
@@@ -420,7 +442,7 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
}
/** ADR-021's raw-event retention on PostgreSQL: rows older than `days` go, in batches; the rollups stay. */
--async function pruneRawEventsPg(db, { days = 30, now = Date.now, batch = 5000 } = {}) {
++async function pruneRawEventsPg(db, { days = 30, botDays = days, now = Date.now, batch = 5000 } = {}) {
const cutoff = sqlTime(now() - days * DAY);
let removed = 0;
for (;;) {
@@@ -428,6 +450,16 @@ async function pruneRawEventsPg(db, { days = 30, now = Date.now, batch = 5000 }
removed += n;
if (n < batch) break;
}
++ // Bot rows can go sooner (botDays): they are in the hourly and daily rollups once their day closes, and are half or
++ // more of the raw table (Live 1.49M of 2.47M rows on 2026-10-22, Network 1.22M of 2.46M on 10-23).
++ if (botDays < days) {
++ const botCutoff = sqlTime(now() - botDays * DAY);
++ for (;;) {
++ const n = await db.exec(`DELETE FROM analytics_events WHERE id IN (SELECT id FROM analytics_events WHERE is_bot = 1 AND created_at < $1 LIMIT ${batch})`, [botCutoff]);
++ removed += n;
++ if (n < batch) break;
++ }
++ }
const today = dayOf(now());
await db.exec('DELETE FROM analytics_visitor_days WHERE day < $1', [dayOf(now() - DAY)]);
await db.exec('DELETE FROM analytics_day_salts WHERE day < $1', [today]);
ddiff --git a/footer.js b/footer.js
iindex 1128bfa..8118334 100644
---- a/footer.js
++++ b/footer.js
@@@ -68,6 +68,7 @@
})());
const NETWORK_URL = 'https://openvibe.network';
++ const REPORT_URL = 'https://openvibe.help/tickets/new';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// One place that knows what the network contains. Adding a property here gives every site
@@@ -95,6 +96,17 @@
{ key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
];
++ // "Report a problem" on every site: a ticket on openvibe.help with this site and page filled in (OpenVibe.Help#81).
++ // The page is the origin and path only, since a query or fragment can carry a sign-in code or a token. Rendered on
++ // the server the page is unknown, so a click fills it in at that moment (a single-page app's route counts).
++ function reportHref() {
++ try {
++ if (typeof location === 'undefined' || !/^https?:$/.test(location.protocol)) return REPORT_URL;
++ return `${REPORT_URL}?${new URLSearchParams({ site: location.hostname.replace(/^www\./, ''), page: (location.origin + location.pathname).slice(0, 500) })}`;
++ } catch { return REPORT_URL; }
++ }
++ const reportLink = () => `<a href="${esc(reportHref())}" data-ov-report>Report a problem</a>`;
++
const SOCIAL = [
{ name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' },
{ name: 'Discord', icon: 'discord', url: 'https://discord.gg/M6MuRUaeJj' },
@@@ -223,6 +235,7 @@
<a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
${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('')}
++ ${reportLink()}
</nav>
${shippedLine(c)}
<span class="ovf-compact-meta">Open source · ${year}</span>
@@@ -259,6 +272,7 @@
<nav class="ovf-col" aria-label="Legal">
<h3>Legal</h3>
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
++ ${reportLink()}
</nav>
</div>
<div class="ovf-account" id="ovf-account" hidden></div>
@@@ -393,6 +407,12 @@
_mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
_mounted.setAttribute('data-variant', _cfg.variant);
_mounted.innerHTML = buildHTML(_cfg);
++ 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(); };
++ _mounted.addEventListener('click', fresh);
++ _mounted.addEventListener('auxclick', fresh);
++ }
try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
fillAccount(_cfg);
if (_cfg.shipped !== false) loadShipped();
ddiff --git a/island.js b/island.js
iindex 2c3a418..56a4dba 100644
---- a/island.js
++++ b/island.js
@@@ -24,9 +24,9 @@
.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}
.ov-island[hidden]{display:none}
.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}
--.ov-island{--ovis-c:var(--accent,#3b82f6)}
--.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e)}
--.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444)}
++.ov-island{--ovis-c:var(--accent,#3b82f6);--ovis-t:var(--ovis-c)}
++.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e);--ovis-t:var(--success-text,var(--success,#22c55e))}
++.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444);--ovis-t:var(--danger-text,var(--danger,#ef4444))}
.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}
.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}
.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}
@@@ -34,9 +34,9 @@
.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}
.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}
.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}
--.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6)}
--.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e)}
--.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444)}
++.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6);--ovis-t:var(--ovis-c)}
++.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e);--ovis-t:var(--success-text,var(--success,#22c55e))}
++.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444);--ovis-t:var(--danger-text,var(--danger,#ef4444))}
.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}
.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}
.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}
@@@ -48,7 +48,7 @@
.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}
.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
--.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-c);background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}
++.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-t,var(--ovis-c));background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}
.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}
.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}
@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}
ddiff --git a/items.css b/items.css
iindex 3e07824..ceb3dc3 100644
---- a/items.css
++++ b/items.css
@@@ -253,6 +253,72 @@ html[data-ov-motion=reduced] .name-fx-vegas::after { animation: none !important;
.name-fx-vegas { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)); }
}
++/* 2fat Bass Drop (2026-10-23, one of a kind: 2fat, who brings the old classic bangers back): the name thumps on the beat,
++ a big drop then a smaller one, in club lights, with an equalizer dancing before it and a speaker after it. Only its
++ one holder wears it (fx_twofat is never in the catalog). The equalizer and the speaker are pseudo-elements, so they
++ carry their own fill and motion rules (the Calm setting and the system preference). */
++.name-fx-twofat {
++ background: linear-gradient(90deg, #ff2fd6 0%, #a855ff 25%, #3de0ff 50%, #a855ff 75%, #ff2fd6 100%);
++ background-size: 200% 100%;
++ -webkit-background-clip: text;
++ background-clip: text;
++ -webkit-text-fill-color: transparent;
++ display: inline-block;
++ font-weight: 900;
++ letter-spacing: .03em;
++ white-space: nowrap;
++ transform-origin: 50% 70%;
++ animation: nameFxTwofatLights 3s linear infinite, nameFxTwofatThump 1s ease-out infinite;
++}
++.name-fx-twofat::before {
++ content: '▂▅▇▃▆';
++ display: inline-block;
++ margin-right: .3em;
++ color: #3de0ff;
++ -webkit-text-fill-color: #3de0ff;
++ text-shadow: 0 0 4px rgba(61, 224, 255, .8);
++ font-size: .6em;
++ letter-spacing: .12em;
++ vertical-align: .1em;
++ animation: nameFxTwofatEq .5s steps(1, end) infinite;
++}
++.name-fx-twofat::after {
++ content: '🔊';
++ display: inline-block;
++ margin-left: .3em;
++ -webkit-text-fill-color: initial;
++ font-size: .85em;
++ animation: nameFxTwofatSpeaker 1s ease-out infinite;
++}
++@keyframes nameFxTwofatLights {
++ 0% { background-position: 0% 50%; }
++ 100% { background-position: 200% 50%; }
++}
++/* The beat: a big thump, then a smaller one, the glow flaring pink, then blue. */
++@keyframes nameFxTwofatThump {
++ 0% { transform: scale(1.12); filter: drop-shadow(0 0 6px rgba(255, 47, 214, .95)); }
++ 18% { transform: scale(1); filter: drop-shadow(0 0 2px rgba(168, 85, 255, .6)); }
++ 50% { transform: scale(1.05); filter: drop-shadow(0 0 4px rgba(61, 224, 255, .85)); }
++ 68%, 100% { transform: scale(1); filter: drop-shadow(0 0 2px rgba(168, 85, 255, .6)); }
++}
++/* The equalizer jumps four times a beat (a browser that cannot animate `content` shows the first frame). */
++@keyframes nameFxTwofatEq {
++ 0% { content: '▂▅▇▃▆'; }
++ 25% { content: '▅▃▆▇▂'; }
++ 50% { content: '▇▆▂▅▃'; }
++ 75% { content: '▃▇▅▂▆'; }
++}
++@keyframes nameFxTwofatSpeaker {
++ 0% { transform: scale(1.25); }
++ 25%, 100% { transform: scale(1); }
++}
++html[data-ov-motion=reduced] .name-fx-twofat, html[data-ov-motion=reduced] .name-fx-twofat::before,
++html[data-ov-motion=reduced] .name-fx-twofat::after { animation: none !important; }
++@media (prefers-reduced-motion: reduce) {
++ .name-fx-twofat, .name-fx-twofat::before, .name-fx-twofat::after { animation: none !important; }
++ .name-fx-twofat { filter: drop-shadow(0 0 3px rgba(255, 47, 214, .9)); }
++}
++
/* Community badges (network.badge.image@1): the reviewed image, a little larger than the text, just before the name. */
.ov-badge { display: inline-block; width: 1.15em; height: 1.15em; margin-right: 3px; vertical-align: -0.2em; border-radius: 3px; object-fit: contain; }
@@@ -283,6 +349,7 @@ html[data-ov-motion=reduced] .name-fx-vegas::after { animation: none !important;
.px-stars .ov-px-p { color: #fbbf24; }
.px-void .ov-px-p { color: #9900ff; }
.px-vegas .ov-px-p { color: #ffcf3f; text-shadow: 0 0 4px rgba(255, 61, 127, .75); }
++.px-twofat .ov-px-p { color: #ff2fd6; text-shadow: 0 0 4px rgba(61, 224, 255, .8); }
@media (prefers-reduced-motion: reduce) {
.ov-fx[class*="name-fx-"], .ov-hat { animation: none !important; }
ddiff --git a/package-lock.json b/package-lock.json
iindex 5193a4a..0d6fadf 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.9.0",
++ "version": "4.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.9.0",
++ "version": "4.10.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 0f0ecd5..88b8368 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.9.0",
++ "version": "4.10.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/showcase.css b/showcase.css
iindex 9dc0122..161344a 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -43,7 +43,7 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}
.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}
.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}
--.sc-yes{color:var(--success,#22c55e);font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
++.sc-yes{color:var(--success-text,var(--success,#22c55e));font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}
ddiff --git a/showcase.js b/showcase.js
iindex a5026f4..64c3119 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -213,7 +213,7 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}
.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}
.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}
--.sc-yes{color:var(--success,#22c55e);font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
++.sc-yes{color:var(--success-text,var(--success,#22c55e));font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}
ddiff --git a/test/analytics-pg.test.js b/test/analytics-pg.test.js
iindex 4c4dbcb..ecc6f98 100644
---- a/test/analytics-pg.test.js
++++ b/test/analytics-pg.test.js
@@@ -40,6 +40,33 @@ const res = (status = 200) => ({ statusCode: status });
assert.ok(stats.timeBuckets.length >= 1 && /^2026-09-28 12:\d+$/.test(stats.timeBuckets[0].bucket), JSON.stringify(stats.timeBuckets[0]));
assert.strictEqual(stats.responsePercentiles.max, 40);
assert.ok(Array.isArray(stats.peakHours) && stats.peakHours[0].hour_of_day === 12);
++ // The reads that share a scan (4.10.0) give each list its own numbers: a session that signs in counts once in the
++ // session total and once on each side of the signed-in split; no session id counts no visitor; bots stay out.
++ const ev = (path, type, session, auth, status, ms, extra = {}) => db.query(`INSERT INTO analytics_events (service, event_type, path, method,
++ status_code, response_time_ms, session_id, authenticated, is_bot, bot_type, device_type, created_at) VALUES ('probe', $1, $2, 'GET', $3, $4, $5, $6, $7, $8, $9, '2026-09-28 11:30:00')`,
++ [type, path, status, ms, session, auth, extra.bot ? 1 : 0, extra.botType || null, extra.device || 'desktop']);
++ await ev('/a', 'pageview', 's1', 0, 200, 10);
++ await ev('/a', 'pageview', 's1', 1, 200, 20);
++ await ev('/a', 'pageview', 's2', 0, 404, 30);
++ await ev('/api/x', 'api_call', 's2', 0, 500, 40, { device: 'mobile' });
++ await ev('/api/x', 'api_call', 's3', 1, 200, 50);
++ await ev('/api/x', 'api_call', null, 0, 200, null);
++ await ev('/a', 'pageview', 'b1', 0, 200, 5, { bot: true, botType: 'googlebot' });
++ await ev('/robots.txt', 'pageview', 'b2', 0, 301, 5, { bot: true });
++ const p = await a.getStats({ days: 7, service: 'probe' });
++ const byKey = (rows, k) => Object.fromEntries(rows.map((r) => [r[k], r]));
++ assert.deepStrictEqual(p.topPages.map((r) => [r.path, r.hits, r.visitors]).sort(), [['/a', 3, 2], ['/api/x', 3, 2]]);
++ assert.deepStrictEqual(Object.fromEntries(p.statusCodes.map((r) => [r.group_code, r.cnt])), { '2xx': 5, '3xx': 1, '4xx': 1, '5xx': 1 });
++ assert.deepStrictEqual(p.botBreakdown.map((r) => [r.bot_type, r.hits]).sort(), [[null, 1], ['googlebot', 1]]);
++ assert.deepStrictEqual(p.deviceBreakdown, [{ device_type: 'desktop', cnt: 5 }, { device_type: 'mobile', cnt: 1 }]);
++ assert.deepStrictEqual(p.authBreakdown, { authenticated: 2, anonymous: 4, unique_authenticated_users: 2, total_sessions: 3 });
++ assert.strictEqual(p.sessionCount, 3);
++ assert.deepStrictEqual(p.bandwidth, { total_requests: 6, page_requests: 3, api_requests: 3, estimated_bytes: 156000 });
++ assert.deepStrictEqual(p.topApiEndpoints, [{ path: '/api/x', method: 'GET', hits: 3, avg_ms: 45, errors: 1 }]);
++ assert.deepStrictEqual(p.slowestEndpoints, [{ path: '/a', method: 'GET', hits: 3, avg_ms: 20, max_ms: 30 }], 'two timed calls are too few to rank');
++ assert.deepStrictEqual(p.peakHours, [{ hour_of_day: 11, hits: 6, visitors: 3, avg_ms: 30 }]);
++ assert.deepStrictEqual(byKey(p.authTrend, 'bucket')['2026-09-28'], { bucket: '2026-09-28', auth_visitors: 2, anon_visitors: 2, auth_hits: 2, anon_hits: 4 });
++ await db.query("DELETE FROM analytics_events WHERE service = 'probe'");
const overview = await a.getOverview(7);
assert.ok(Array.isArray(overview.services));
const bots = await a.getBotAnalysis(7);
@@@ -49,6 +76,13 @@ const res = (status = 200) => ({ statusCode: status });
now = T0 + 86400000;
await a.aggregate();
assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM analytics_visitor_days')), 0);
++ // botDays: a bot row goes after botDays, a person's after days.
++ const ins = (isBot, at) => db.query(`INSERT INTO analytics_events (service, event_type, path, is_bot, created_at) VALUES ('botdays', 'pageview', '/', $1, $2)`, [isBot, at]);
++ await ins(1, '2026-09-29 12:00:00'); await ins(0, '2026-09-29 12:00:00'); await ins(1, '2026-10-06 12:00:00');
++ const bp = await pruneRawEventsPg(db, { days: 30, botDays: 7, now: () => Date.parse('2026-10-07T00:00:00Z') });
++ assert.strictEqual(bp.removed, 1, 'only the bot row older than 7 days');
++ assert.strictEqual(Number(await db.value("SELECT COUNT(*) FROM analytics_events WHERE service = 'botdays'")), 2);
++ await db.query("DELETE FROM analytics_events WHERE service = 'botdays'");
now = T0 + 31 * 86400000;
const r = await pruneRawEventsPg(db, { days: 30, now: () => now });
assert.strictEqual(r.removed, 3);
ddiff --git a/test/items.test.js b/test/items.test.js
iindex 0d75b68..65bf8c7 100644
---- a/test/items.test.js
++++ b/test/items.test.js
@@@ -93,11 +93,12 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
assert.strictEqual(await items.decorate(d2), 1);
assert.deepStrictEqual([...d2.getElementById('x').classList], []);
-- // The stylesheet: Live's 14 name effects (13 and the one-of-a-kind Josh Vegas), an inline-block wrapper, and reduced motion.
++ // The stylesheet: Live's 15 name effects (13 and the one-of-a-kind Josh Vegas and 2fat Bass Drop), an inline-block wrapper, and reduced motion.
const css = fs.readFileSync(path.join(__dirname, '..', 'items.css'), 'utf8');
const fx = [...css.matchAll(/^\.(name-fx-[a-z]+) \{/gm)].map((m) => m[1]);
-- assert.strictEqual(fx.length, 14, `14 name effects (${fx.join(', ')})`);
-- assert.ok(fx.includes('name-fx-rainbow') && fx.includes('name-fx-divine') && fx.includes('name-fx-vegas'));
++ assert.strictEqual(fx.length, 15, `15 name effects (${fx.join(', ')})`);
++ assert.ok(fx.includes('name-fx-rainbow') && fx.includes('name-fx-divine') && fx.includes('name-fx-vegas') && fx.includes('name-fx-twofat'));
++ assert.match(css, /html\[data-ov-motion=reduced\] \.name-fx-twofat::after \{ animation: none !important; \}/, 'the Calm setting stops the bass drop');
assert.match(css, /html\[data-ov-motion=reduced\] \.name-fx-vegas::after \{ animation: none !important; \}/, 'the Calm setting stops the reels');
assert.match(css, /\.ov-fx \{ display: inline-block; \}/);
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*animation: none !important/);
ddiff --git a/test/shared-modules.test.js b/test/shared-modules.test.js
iindex 09a62eb..16671c3 100644
---- a/test/shared-modules.test.js
++++ b/test/shared-modules.test.js
@@@ -69,6 +69,18 @@ console.log('shared modules: all checks passed');
assert.ok(own.includes('href="/updates"') && own.includes('data-ov-shipped="latest"'), 'a site with its own log links to it, compact too');
const off = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'full', shipped: false });
assert.ok(!off.includes('data-ov-shipped') && !off.includes('>Updates<'), 'shipped: false leaves it out');
++ // "Report a problem" (4.10.0): a ticket on openvibe.help naming this site and page, the page without its query or
++ // fragment (a sign-in code or a token can ride there); server-rendered, the plain form.
++ assert.ok(html.includes('data-ov-report>Report a problem</a>'), 'the full footer has Report a problem');
++ assert.ok(own.includes('data-ov-report>Report a problem</a>'), 'the compact footer has it too');
++ const rp = vm.createContext({ console, URLSearchParams }); rp.globalThis = rp; rp.window = rp; rp.localStorage = { getItem: () => null, setItem() {} };
++ rp.location = { protocol: 'https:', hostname: 'www.openvibe.live', origin: 'https://www.openvibe.live', pathname: '/@kim', href: 'https://www.openvibe.live/@kim?code=SECRET#t=1' };
++ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), rp);
++ const rh = rp.OpenVibeFooter.buildHTML({ service: 'live', variant: 'compact' });
++ assert.ok(rh.includes(`href="https://openvibe.help/tickets/new?site=openvibe.live&amp;page=${encodeURIComponent('https://www.openvibe.live/@kim')}"`), rh.match(/href="[^"]*tickets\/new[^"]*"/));
++ assert.ok(!rh.includes('SECRET'), 'the query never reaches the link');
++ const srv = require('../footer.js').ssr({ service: 'live', variant: 'full' });
++ assert.ok(srv.includes('href="https://openvibe.help/tickets/new" data-ov-report'), 'server-rendered: the plain form');
for (const [host, id] of [['openvibe.wiki', 'wiki'], ['openvibe.live', 'live'], ['pdf.openvibe.tools', 'pdf'], ['openvibe.tools', 'tools'], ['openre.stream', 'openre']]) {
const c2 = vm.createContext({ console }); c2.globalThis = c2; c2.window = c2; c2.location = { hostname: host, href: `https://${host}/` }; c2.localStorage = { getItem: () => null, setItem() {} };
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), c2);