openvibers/openvibe.shared Commits

4.43.0: a theme for one site — shell names the site on <html>, theme-loader asks the account for that site's exception (#183)

OP OpenVibers committed 15 days ago · 6c2a2f4019 · 9 files, +61 −7

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 602db5d..e976d77 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,14 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.43.0 — 2026-10-29
++
++- **A theme for one site.** `shell.page` writes `<html data-ov-service="<site>">` (the page's service id, when it is a short
++ lowercase name), and theme-loader.js asks the account for `/api/themes/me/active?service=<site>`. When the person has
++ set an exception for that site in OpenVibe.Design ("Games in Neon Tokyo", OpenVibe.Network#419), the site wears it;
++ everywhere else, and on a Network that does not know the parameter yet, the account's theme as before.
++ `OpenVibeThemeLoader.site()` names the site.
++
## 4.42.0 — 2026-10-29
- **OpenVibe components (`openvibe-shared/components`, also `/shared/components.js` as `window.OpenVibeComponents`).** The kit's components as a contract that is not HTML. Eleven components (button, card, rail, row, list, section, fold, tabs, numbers, notice, empty state), each described by its anatomy and the tokens every part wears, its typed props, variants, states, accessibility and phone/tablet/computer behaviour, with a reference view for Jetpack Compose and for SwiftUI on the same tokens. **Kit documents**: a screen as a JSON tree of components, validated strictly (types, enums, required props, children, depth, size, https or same-site links) and rendered through kit.js exactly as the direct builder calls would. The server-driven UI shape a native app can render with its own views. docs/components.md.
ddiff --git a/README.md b/README.md
iindex f5006d5..49faf28 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.42.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.43.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 2dc754f..860c49d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.42.0",
++ "version": "4.43.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/package-lock.json b/package-lock.json
iindex 4d9060c..b9d96fb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.42.0",
++ "version": "4.43.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.42.0",
++ "version": "4.43.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex f759f43..83f62b3 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.42.0",
++ "version": "4.43.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/shell.js b/shell.js
iindex 5ff9595..3e37ede 100644
---- a/shell.js
++++ b/shell.js
@@@ -92,6 +92,9 @@ function serviceOf(o) {
return o.service || (o.navbar && o.navbar.service) || (o.footer && o.footer.service);
}
++// The site's id as theme-loader.js reads it from <html data-ov-service>: a short lowercase name (live, chat, bot…), else none.
++const siteId = (o) => { const s = String(serviceOf(o) || '').toLowerCase(); return /^[a-z][a-z0-9-]{1,31}$/.test(s) ? s : ''; };
++
// A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page
// has a canonical URL to make it absolute against. `image: null` opts out.
function defaultImage(o) {
@@@ -134,7 +137,8 @@ function page(o = {}) {
const bodyAttributes = { ...(o.bodyClass ? { class: o.bodyClass } : {}), ...(o.bodyAttributes || {}) };
return [
'<!doctype html>',
-- `<html lang="${esc(o.lang || 'en')}">`,
++ // data-ov-service: which site this is, so theme-loader.js can ask for the person's theme for this site (4.43.0).
++ `<html lang="${esc(o.lang || 'en')}"${siteId(o) ? ` data-ov-service="${siteId(o)}"` : ''}>`,
'<head>',
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
ddiff --git a/test/shell.test.js b/test/shell.test.js
iindex 2b9a24e..503cb16 100644
---- a/test/shell.test.js
++++ b/test/shell.test.js
@@@ -73,4 +73,11 @@ assert.match(withSummary, /"@type":"WebPage"/, 'WebPage JSON-LD with the summary
assert.match(withSummary, /<noscript><section data-ai-summary>/, 'noscript summary in the body');
assert.ok(withSummary.indexOf('</head>') < withSummary.indexOf('<noscript><section data-ai-summary>'), 'summary body sits after </head>');
++// 4.43.0: the page says which site it is, for theme-loader's per-site theme.
++{
++ const shellLib = require('../shell');
++ assert.match(shellLib.page({ name: 'OpenVibe.Bot', service: 'bot', body: '' }), /<html lang="en" data-ov-service="bot">/);
++ assert.match(shellLib.page({ name: 'X', navbar: { service: 'games' }, body: '' }), /data-ov-service="games"/);
++ assert.doesNotMatch(shellLib.page({ name: 'OpenVibe.Design', body: '' }), /data-ov-service/, 'a display name is not an id');
++}
console.log('shell: all checks passed');
ddiff --git a/test/theme-try.test.js b/test/theme-try.test.js
iindex 66bf5a4..2ed935d 100644
---- a/test/theme-try.test.js
++++ b/test/theme-try.test.js
@@@ -83,4 +83,30 @@ assert.strictEqual(p.session.get('ov_try'), 'daylight');
p.L.try.start('<b>');
assert.strictEqual(painted(p), 'daylight', 'a bad name changes nothing');
++// 6. 4.43.0: the account's sync asks for this site's exception (<html data-ov-service>); a bad id asks for none.
++{
++ const urls = [];
++ const run = (svc) => {
++ const attrs = new Map(svc ? [['data-ov-service', svc]] : []);
++ const html = { style: { setProperty() {}, removeProperty() {} }, setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k) };
++ const sandbox = { Date, JSON, Object, String, Number, Math, Array, Promise, Error, URL, URLSearchParams, setTimeout() {}, clearTimeout() {},
++ document: { documentElement: html, readyState: 'complete', cookie: '', head: null, body: null, querySelector: () => null, getElementById: () => null, referrer: '', visibilityState: 'visible', addEventListener() {} },
++ localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
++ location: { hostname: 'openvibe.games', protocol: 'https:', search: '', href: 'https://openvibe.games/' }, history: { replaceState() {} },
++ getComputedStyle: () => ({ getPropertyValue: () => '' }), matchMedia: () => ({ matches: true, addEventListener() {} }),
++ fetch: (url) => { urls.push(url); return Promise.resolve({ ok: false, json: () => Promise.resolve(null) }); },
++ CustomEvent: class {}, addEventListener() {}, dispatchEvent() {} };
++ sandbox.window = sandbox;
++ sandbox.globalThis = sandbox;
++ vm.runInNewContext(SRC, sandbox);
++ return sandbox.OpenVibeThemeLoader;
++ };
++ const L = run('games');
++ assert.ok(urls.some((u) => /\/api\/themes\/me\/active\?service=games$/.test(u)), urls.join(' '));
++ assert.strictEqual(L.site(), 'games');
++ urls.length = 0;
++ run('Games<script>');
++ assert.ok(urls.some((u) => /\/api\/themes\/me\/active$/.test(u)) && !urls.some((u) => /service=/.test(u)), 'a bad id asks for the account theme only');
++}
++
console.log('theme try: paints without saving, follows the tab, survives repaints, Stop restores, only catalog names');
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 667bcb4..5485c7a 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -562,12 +562,19 @@
// so this now works on sites where the cookie isn't readable from JS — previously the sync
// silently never ran there and the user's theme appeared to be "lost".
let _lastSync = 0;
++ /** This site's id from <html data-ov-service> (openvibe-shared/shell writes it), or '' (4.43.0). */
++ function siteService() {
++ try { const s = document.documentElement.getAttribute('data-ov-service') || ''; return /^[a-z][a-z0-9-]{1,31}$/.test(s) ? s : ''; } catch { return ''; }
++ }
function syncFromServer() {
if (typeof fetch === 'undefined') return;
_lastSync = Date.now();
const token = authToken();
const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
-- fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })
++ // The site's own exception, when the person set one in OpenVibe.Design ("Games in Neon Tokyo"): Network answers it
++ // for ?service=<this site> and the account's theme otherwise (4.43.0).
++ const site = siteService();
++ fetch(_config.apiBase + '/api/themes/me/active' + (site ? '?service=' + encodeURIComponent(site) : ''), { headers: headers, credentials: 'include' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text, seasonal: data.display.seasonal || cur.seasonal, scheme: data.display.scheme || cur.scheme }) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true }); } }
@@@ -620,6 +627,8 @@
// Try a theme on this tab without saving it (?ov-try=): start(id), stop(), current() → the id being tried, or null.
try: { start: function (id) { startTry(String(id || '')); }, stop: function () { stopTry(true); }, current: function () { return _trying ? _trying.id : null; } },
getCurrent: getCurrent,
++ // This site's id (<html data-ov-service>), the key of its theme exception on the account (4.43.0).
++ site: function () { return siteService(); },
resolveAndApply: resolveAndApply,
init: function (opts) {
if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {