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)
Changes
@@@ -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.@@@ -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 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.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)",@@@ -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",@@@ -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": {@@@ -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">',@@@ -73,4 +73,11 @@ assert.match(withSummary, /"@type":"WebPage"/, 'WebPage JSON-LD with the summaryassert.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');@@@ -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');@@@ -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()) {
@@ -6,6 +6,14 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,14 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 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.
- **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.
@@ -2,7 +2,7 @@
@@ -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.
> 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
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.42.0",
+ "version": "4.43.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)",
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.42.0",
+ "version": "4.43.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.42.0",
+ "version": "4.43.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.42.0",
+ "version": "4.43.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": {
@@ -92,6 +92,9 @@ function serviceOf(o) {
@@ -92,6 +92,9 @@ function serviceOf(o) {
return o.service || (o.navbar && o.navbar.service) || (o.footer && o.footer.service);
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
// 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.
// has a canonical URL to make it absolute against. `image: null` opts out.
function defaultImage(o) {
function defaultImage(o) {
@@ -134,7 +137,8 @@ function page(o = {}) {
@@ -134,7 +137,8 @@ function page(o = {}) {
const bodyAttributes = { ...(o.bodyClass ? { class: o.bodyClass } : {}), ...(o.bodyAttributes || {}) };
const bodyAttributes = { ...(o.bodyClass ? { class: o.bodyClass } : {}), ...(o.bodyAttributes || {}) };
return [
return [
'<!doctype html>',
'<!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>',
'<head>',
'<meta charset="utf-8">',
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
@@ -73,4 +73,11 @@ assert.match(withSummary, /"@type":"WebPage"/, 'WebPage JSON-LD with the summary
@@ -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.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>');
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');
console.log('shell: all checks passed');
@@ -83,4 +83,30 @@ assert.strictEqual(p.session.get('ov_try'), 'daylight');
@@ -83,4 +83,30 @@ assert.strictEqual(p.session.get('ov_try'), 'daylight');
p.L.try.start('<b>');
p.L.try.start('<b>');
assert.strictEqual(painted(p), 'daylight', 'a bad name changes nothing');
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');
console.log('theme try: paints without saving, follows the tab, survives repaints, Stop restores, only catalog names');
@@ -562,12 +562,19 @@
@@ -562,12 +562,19 @@
// so this now works on sites where the cookie isn't readable from JS — previously the sync
// 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".
// silently never ran there and the user's theme appeared to be "lost".
let _lastSync = 0;
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() {
function syncFromServer() {
if (typeof fetch === 'undefined') return;
if (typeof fetch === 'undefined') return;
_lastSync = Date.now();
_lastSync = Date.now();
const token = authToken();
const token = authToken();
const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
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 (r) { return r.ok ? r.json() : null; })
.then(function (data) {
.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 }); } }
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 @@
@@ -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 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; } },
try: { start: function (id) { startTry(String(id || '')); }, stop: function () { stopTry(true); }, current: function () { return _trying ? _trying.id : null; } },
getCurrent: getCurrent,
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,
resolveAndApply: resolveAndApply,
init: function (opts) {
init: function (opts) {
if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {
if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {