openvibers/openvibe.shared Commits

Shared 1.29.1: themes repaint the canvas through the variables (a page's own theme switcher changes the background too), save() applies the theme it saves, and themes without --on-accent get readable button text derived by WCAG luminance (near-black on bright accents); onColor(hex) exported. Navbar brand compact: 6px mark gap, 2px padding, side padding clamp(8px,1.6vw,16px). npm test 42/42.

OP OpenVibers committed 29 Sept 2026 · ca7d2355ba · 5 files, +32 −10


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8a2ab56..8066f2d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,10 @@ 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).
++## 1.29.1 — 2026-09-28
++
++**Themes repaint the whole page and keep button text readable.** `applyVars` sets the canvas through the variables (`var(--bg-primary, value)`), so a page with its own theme switcher that sets `--bg-primary` later repaints the background too, instead of leaving the first theme's colour behind; `save()` applies the theme it saves, so every token, the canvas and the browser UI follow at once. A theme without its own `--on-accent` (custom colours, older server themes) gets one derived by WCAG relative luminance, near-black on bright accents (a lime green) and white on dark ones; `OpenVibeThemeLoader.onColor(hex)` is exported for pages that paint their own accent surfaces. **Navbar brand, compact:** the mark and the name sit 6px apart with 2px padding, the ".Live" part hugs the name, and the bar's side padding follows the viewport (`clamp(8px, 1.6vw, 16px)`), so narrow screens keep more room for links.
++
## 1.29.0 — 2026-09-28
**`openvibe-shared/analytics/pg`: request analytics on PostgreSQL** (ADR-035; ADR-021's privacy bounds unchanged). `AnalyticsTrackerPg` is the tracker on an openvibe-sdk/db handle, with the same tables and `getStats`, `getOverview` and `getBotAnalysis` shapes; `analyticsSchema()` is the DDL for the service's migrations; `pruneRawEventsPg` is the 30-day retention in batches. The request path never waits for the database: rows buffer in memory and `flush()` writes them in one transaction every 5 s (or at 100). Unique visitors stay right across processes: the day's salt is shared through `analytics_day_salts`, so the visitor hash is computed at flush, while (address, user agent) wait in memory until then, as the rate counters always did; no address reaches the database. Session ids stay per process, as they were. Every SUM is cast to bigint (numeric comes back as text). `test/analytics-pg.test.js` on PGlite: two trackers count one visitor once, DNT records nothing, rollups, dashboards and retention. Needed by Network's and Live's PostgreSQL passes.
ddiff --git a/STATUS.json b/STATUS.json
iindex f51fef4..6ebe326 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "1.29.0",
++ "version": "1.29.1",
"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/navbar.js b/navbar.js
iindex 50e0f49..3084236 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -57,7 +57,7 @@
s.textContent = `
.openvibe-navbar {
position: sticky; top: 0; z-index: 10000;
-- height: 52px; display: flex; align-items: center; padding: 0 16px; gap: 8px;
++ height: 52px; display: flex; align-items: center; padding: 0 clamp(8px, 1.6vw, 16px); gap: 6px;
background: var(--bg-secondary, #252530);
border-bottom: 1px solid var(--border, #333340);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
@@@ -67,19 +67,19 @@
/* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
.openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }
-- .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }
++ .openvibe-navbar .openvibe-navbar-brand { gap: 6px; padding: 2px; border-radius: 12px; margin-right: 0; }
.openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }
.openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
.openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }
-- .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }
++ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 1px; border-radius: 6px; transition: color .15s, background .15s; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }
.openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }
.openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
-- .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }
++ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0; opacity: .5; }
.openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
-- .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
++ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 2px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
.ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
.ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }
.ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }
@@@ -198,7 +198,7 @@
.openvibe-navbar-dropdown-header .ud-wallet a { display: inline-flex; align-items: center; gap: 5px; margin-top: 5px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; text-decoration: none; color: #fbbf24; background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); }
.openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
-- .openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
++ .openvibe-navbar-links { display: flex; align-items: center; gap: 2px; margin-left: 2px; }
.openvibe-navbar-links a {
padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
color: var(--text-secondary, #b0b0b8); text-decoration: none;
ddiff --git a/package.json b/package.json
iindex 5d0313d..cf03bc2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.29.0",
++ "version": "1.29.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
ddiff --git a/theme-loader.js b/theme-loader.js
iindex bcc8ba5..c5190b1 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -98,8 +98,22 @@
// Every token any built-in theme sets. Applying a theme clears the ones it does not set, so a
// theme that names no --success cannot inherit the previous theme's --success on <html>.
const KNOWN_PROPS = (function () { const out = {}; for (const id in THEMES) for (const k in THEMES[id]) out[k] = true; return Object.keys(out); })();
++ /** Readable text on a colour: near-black or white, whichever contrasts more (WCAG relative luminance). */
++ function onColor(c) {
++ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || '').trim());
++ if (!m) return null;
++ const h = m[1].length === 3 ? m[1].split('').map((x) => x + x).join('') : m[1];
++ const lin = (i) => { const v = parseInt(h.slice(i, i + 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
++ const L = 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4);
++ return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#0b0d10' : '#ffffff';
++ }
function applyVars(vars) {
if (!vars || typeof vars !== 'object') return;
++ // A theme without its own text-on-accent colours (custom and older server themes) gets readable ones derived.
++ if (vars['--accent'] && !vars['--on-accent']) {
++ const on = onColor(vars['--accent']);
++ if (on) vars = Object.assign({}, vars, { '--on-accent': on, '--on-accent-strong': vars['--on-accent-strong'] || onColor(vars['--accent-strong'] || vars['--accent']) || on });
++ }
const el = document.documentElement;
for (let i = 0; i < KNOWN_PROPS.length; i++) if (!(KNOWN_PROPS[i] in vars)) el.style.removeProperty(KNOWN_PROPS[i]);
for (const [prop, val] of Object.entries(vars)) {
@@@ -109,8 +123,10 @@
const scheme = vars['--color-scheme'];
if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }
// The inline critical style paints a fixed dark canvas before any stylesheet; keep it in step with the theme.
-- if (vars['--bg-primary']) el.style.background = vars['--bg-primary'];
-- if (vars['--text-primary']) el.style.color = vars['--text-primary'];
++ // Through the variables (with the value as fallback): a page that sets --bg-primary later (its own theme
++ // switcher) repaints the canvas too, instead of leaving the first theme's colour behind.
++ if (vars['--bg-primary']) el.style.background = `var(--bg-primary, ${vars['--bg-primary']})`;
++ if (vars['--text-primary']) el.style.color = `var(--text-primary, ${vars['--text-primary']})`;
syncBrowserUi();
}
@@@ -217,6 +233,7 @@
const base = variables || THEMES[themeId] || {};
const vars = custom ? Object.assign({}, base, custom) : base;
if (!vars || !Object.keys(vars).length) return;
++ applyVars(vars); // saving a theme shows it: every token, the canvas and the browser UI follow at once
cacheLocally(themeId, vars);
if (opts && opts.sync === false) return;
if (typeof fetch === 'undefined') return;
@@@ -335,6 +352,7 @@
display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS },
apply: applyById,
applyVars: applyVars,
++ onColor: onColor,
save: save,
getCurrent: getCurrent,
resolveAndApply: resolveAndApply,