openvibers/openvibe.shared Commits

4.41.0: try a theme on any OpenVibe page (?ov-try=), with Use it and Stop (#181)

OP OpenVibers committed 14 days ago · dc09847ed7 · 7 files, +203 −5

theme-loader paints a catalog theme on the site without saving it, keeps it for the tab, and wears it through every repaint. A bar says Trying <name> with Use it (save to the account) and Stop (back to the account's theme). Only a catalog name is read from the URL. OpenVibe.Design's "Try it on Live" links use it.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 28488e2..da0dfeb 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.41.0 — 2026-10-29
++
++- **Try a theme on any OpenVibe page: `?ov-try=<theme>`** (theme-loader.js). It paints a catalog theme on the site without saving it: built-in themes at once, gallery themes from Network's catalog. The try is kept for the tab (sessionStorage) as the person moves around, and every repaint wears the tried theme (the account's sync, another tab, the device's light/dark). A bar at the bottom, above the app bar and 44 px to the touch, says "Trying <name>" with **Use it** (saves to the account, as the studio does) and **Stop** (back to the account's theme; `?ov-try=off` does the same). Only a catalog name is read from the URL, never colours, and nothing is saved until Use it. `OpenVibeThemeLoader.try.start(id)` / `.stop()` / `.current()` do the same from a page. OpenVibe.Design links to it ("Try it on Live"). test/theme-try.test.js.
++
## 4.40.0 — 2026-10-29
- **OpenVibe Tokens (`openvibe-shared/tokens`, also `/shared/tokens.js` as `window.OpenVibeTokens`).** A theme as a W3C Design Tokens (DTCG) document of semantic tokens (`color.bg.base`, `color.accent.strong`, `color.status.ok.text` … plus the kit's radius, space, size, type and motion scales), compiled to CSS variables (with the network's names as aliases), Tailwind, Jetpack Compose (tokens and a Material 3 colour scheme), SwiftUI, Flutter, Figma variables and DTCG JSON. A theme's dark and light halves compile into one file. Values are validated (colours and shadows only) and names become identifiers, so nothing typed in the studio reaches generated code. This is the first piece of making the design system native-ready: real Android and iOS apps wear the same theme, without a web view. docs/tokens.md.
ddiff --git a/README.md b/README.md
iindex 9cae809..c03ba1a 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.40.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.41.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 5ae072f..c00c4d6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.40.0",
++ "version": "4.41.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 60c17d4..8bc61c0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.40.0",
++ "version": "4.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.40.0",
++ "version": "4.41.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 9b4cb53..0ef5b12 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.40.0",
++ "version": "4.41.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/theme-try.test.js b/test/theme-try.test.js
nnew file mode 100644
iindex 0000000..66bf5a4
---- /dev/null
++++ b/test/theme-try.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++// Trying a theme on a page (theme-loader.js ?ov-try=, 4.41.0): a catalog theme paints without being saved, follows the
++// tab (sessionStorage), is not undone by the account's sync or the device's light/dark, Stop goes back to the account's
++// theme, and only a catalog name is ever read from the URL. theme-loader.js runs in a small fake browser, as in
++// theme-pairs.test.js. (The bar's Use it and Stop buttons were checked in a real browser for the 4.41.0 PR.)
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { BUILTIN_THEMES } = require('../builtin-themes');
++
++const by = Object.fromEntries(BUILTIN_THEMES.map((t) => [t.id, t]));
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');
++
++function load(search, { store = new Map(), session = new Map(), dark = true } = {}) {
++ const fixed = new Date(2026, 10, 20, 12).getTime(); // out of season
++ class FixedDate extends Date { constructor(...a) { if (a.length) super(...a); else super(fixed); } static now() { return fixed; } }
++ const attrs = new Map(); const props = new Map(); const puts = []; const replaced = [];
++ const html = { style: { setProperty: (k, v) => props.set(k, v), removeProperty: (k) => props.delete(k) }, setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k), props };
++ const sandbox = {
++ Date: FixedDate, 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: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
++ sessionStorage: { getItem: (k) => (session.has(k) ? session.get(k) : null), setItem: (k, v) => session.set(k, String(v)), removeItem: (k) => session.delete(k) },
++ location: { hostname: 'openvibe.live', protocol: 'https:', search, href: `https://openvibe.live/${search}` },
++ history: { state: null, replaceState: (s, t, u) => replaced.push(u) },
++ getComputedStyle: () => ({ getPropertyValue: () => '' }),
++ matchMedia: (q) => ({ matches: /dark/.test(q) ? dark : false, addEventListener() {} }),
++ fetch: (url, o) => { if (o && o.method === 'PUT') puts.push({ url, body: JSON.parse(o.body) }); return Promise.resolve({ ok: false, json: () => Promise.resolve(null) }); },
++ CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
++ addEventListener() {}, dispatchEvent() {},
++ };
++ sandbox.window = sandbox;
++ sandbox.globalThis = sandbox;
++ vm.runInNewContext(SRC, sandbox);
++ return { L: sandbox.OpenVibeThemeLoader, html, puts, store, session, replaced };
++}
++const picked = (id) => new Map([['ov_theme', JSON.stringify({ id, variables: by[id].variables })]]);
++const painted = (p) => p.html.getAttribute('data-theme');
++
++// 1. ?ov-try=<builtin> paints it at once, saves nothing, and is remembered for the tab.
++let p = load('?ov-try=neon-tokyo', { store: picked('forest') });
++assert.strictEqual(painted(p), 'neon-tokyo');
++assert.strictEqual(p.html.getAttribute('data-ov-trying'), 'neon-tokyo');
++assert.strictEqual(p.html.props.get('--accent'), by['neon-tokyo'].variables['--accent']);
++assert.strictEqual(p.L.try.current(), 'neon-tokyo');
++assert.strictEqual(p.session.get('ov_try'), 'neon-tokyo', 'kept for the tab');
++assert.strictEqual(JSON.parse(p.store.get('ov_theme')).id, 'forest', 'the account\'s theme is untouched');
++assert.strictEqual(p.puts.length, 0, 'nothing is saved');
++
++// 2. The next page of the site (no ?ov-try) still wears it; a repaint (the sync, the device) does not undo it.
++p = load('', { store: picked('forest'), session: new Map([['ov_try', 'neon-tokyo']]) });
++assert.strictEqual(painted(p), 'neon-tokyo', 'it follows the tab around the site');
++p.L.scheme.set('light');
++assert.strictEqual(painted(p), 'neon-tokyo', 'a repaint while trying paints the tried theme');
++
++// 3. Stop goes back to the account's theme and forgets the try; ?ov-try=off does too.
++p.L.try.stop();
++assert.strictEqual(p.L.try.current(), null);
++assert.strictEqual(p.session.has('ov_try'), false);
++assert.strictEqual(p.html.getAttribute('data-ov-trying'), null);
++assert.notStrictEqual(painted(p), 'neon-tokyo', 'back to the account\'s theme');
++p = load('?ov-try=off', { store: picked('forest'), session: new Map([['ov_try', 'neon-tokyo']]) });
++assert.strictEqual(painted(p), 'forest');
++assert.strictEqual(p.session.has('ov_try'), false);
++
++// 4. Only a catalog name: anything else in the URL is ignored, and nothing from it reaches the page's styles.
++for (const bad of ['?ov-try=Neon%20Tokyo', '?ov-try=x%22%3E%3Cscript%3E', '?ov-try=--bg-primary:red', '?ov-try=' + 'a'.repeat(80), '?ov-try=-lead']) {
++ p = load(bad, { store: picked('forest') });
++ assert.strictEqual(painted(p), 'forest', bad);
++ assert.strictEqual(p.L.try.current(), null, bad);
++ assert.strictEqual(p.session.has('ov_try'), false, bad);
++}
++// An unknown name (not built in) asks the catalog; when the catalog says no, the page keeps the account's theme.
++p = load('?ov-try=not-a-theme', { store: picked('forest') });
++assert.strictEqual(painted(p), 'forest');
++
++// 5. try.start() from a page (Design's "Try it here" button) works like the URL.
++p = load('', { store: picked('forest') });
++p.L.try.start('daylight');
++assert.strictEqual(painted(p), 'daylight');
++assert.strictEqual(p.session.get('ov_try'), 'daylight');
++p.L.try.start('<b>');
++assert.strictEqual(painted(p), 'daylight', 'a bad name changes nothing');
++
++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 9115d34..667bcb4 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -109,6 +109,7 @@
}
let _season = null;
let _shown = { id: DEFAULT_ID, vars: null, custom: false };
++ let _trying = null; // { id, name, vars } while a theme is tried on this tab (?ov-try=, 4.41.0)
/** Paint the theme `themeId` (its variables `vars`), or the season's theme in its place. → the id painted. */
// ── Light, dark and Auto ─────────────────────────────────
// Every built-in theme has a light and a dark half (PAIRS above). The display preference `scheme` is 'manual' (the
@@@ -133,6 +134,9 @@
function show(themeId, vars, custom) {
_shown = { id: themeId, vars: vars, custom: !!custom };
++ // While a theme is being tried on this tab (?ov-try=), every repaint paints it: the account's sync, another tab,
++ // the device switching light and dark. _shown still follows the real theme, so Stop goes back to it.
++ if (_trying && _trying.vars) return paintTry();
const half = halfFor(themeId, vars, !!custom);
const s = isDefault(half.id) && !half.custom && readDisplay().seasonal !== 'off' ? seasonNow() : null;
const seasonTheme = s ? (modeOf(half.id) === 'light' && s.light ? s.light : s.theme) : null;
@@@ -424,7 +428,109 @@
}
applyDisplay(readDisplay());
++ // ── Try a theme on this page (4.41.0) ──
++ // ?ov-try=<theme> paints that catalog theme on this site without saving it (OpenVibe.Design's "Try it on Live"), and
++ // keeps it for the tab (sessionStorage) while the person moves around the site; ?ov-try=off ends it. A bar at the
++ // bottom says so: Use it saves it to the account, as the studio's Use does; Stop goes back to the account's theme. Only
++ // a catalog name is read from the URL, never colours, and nothing is saved until Use it.
++ const TRY_KEY = 'ov_try';
++ const TRY_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
++ const tryName = function (id) { return String(id).split('-').map(function (w) { return w ? w[0].toUpperCase() + w.slice(1) : w; }).join(' '); };
++ function paintTry() {
++ applyVars(_trying.vars);
++ try {
++ const el = document.documentElement;
++ el.setAttribute('data-theme', _trying.id);
++ el.setAttribute('data-ov-trying', _trying.id);
++ el.removeAttribute('data-ov-season');
++ } catch { /* no DOM */ }
++ syncBrowserUi();
++ return _trying.id;
++ }
++ function readTry() {
++ let id = '';
++ try {
++ const p = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('ov-try') : null;
++ if (p === 'off') { sessionStorage.removeItem(TRY_KEY); return null; }
++ if (p && TRY_ID.test(p)) sessionStorage.setItem(TRY_KEY, p);
++ id = sessionStorage.getItem(TRY_KEY) || '';
++ } catch { id = ''; }
++ return TRY_ID.test(id) ? id : null;
++ }
++ function startTry(id, name) {
++ if (!id || !TRY_ID.test(id)) return;
++ try { sessionStorage.setItem(TRY_KEY, id); } catch { /* private mode: this page only */ }
++ const begin = function (vars, label) {
++ if (!vars || !Object.keys(vars).length) { stopTry(false); return; }
++ _trying = { id: id, name: label || tryName(id), vars: vars };
++ fadeNext();
++ paintTry();
++ whenReady(tryBar);
++ };
++ if (THEMES[id]) return begin(THEMES[id], name);
++ if (typeof fetch === 'undefined') return;
++ // A gallery theme comes from the catalog, once the page has run init({ apiBase }) (the script tag after this one).
++ whenReady(function () {
++ fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(id))
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) { begin(d && d.theme && d.theme.variables, d && d.theme && d.theme.name); })
++ .catch(function () { stopTry(false); });
++ });
++ }
++ function stopTry(repaint) {
++ _trying = null;
++ try { sessionStorage.removeItem(TRY_KEY); } catch { /* */ }
++ try {
++ document.documentElement.removeAttribute('data-ov-trying');
++ const bar = document.getElementById('ov-try-bar');
++ if (bar) bar.remove();
++ const u = new URL(location.href);
++ if (u.searchParams.has('ov-try')) { u.searchParams.delete('ov-try'); history.replaceState(history.state, '', u.pathname + (u.search || '') + u.hash); }
++ } catch { /* no DOM */ }
++ if (repaint !== false) { fadeNext(); resolveAndApply(); }
++ }
++ function whenReady(fn) {
++ if (typeof document === 'undefined') return;
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn();
++ }
++ function tryBar() {
++ if (!_trying || document.getElementById('ov-try-bar') || !document.body) return;
++ const t = _trying;
++ const bar = document.createElement('div');
++ bar.id = 'ov-try-bar';
++ bar.setAttribute('role', 'status');
++ bar.style.cssText = 'position:fixed;left:0;right:0;margin:0 auto;width:max-content;bottom:calc(var(--ovnav-appbar-h,0px) + 12px);z-index:2147483000;display:flex;align-items:center;gap:6px;max-width:calc(100vw - 24px);box-sizing:border-box;padding:4px 4px 4px 16px;border-radius:999px;border:1px solid var(--border-light,var(--border,#2c3d5c));background:var(--bg-card,#131c2e);color:var(--text-primary,#e6edf7);box-shadow:0 10px 30px rgba(0,0,0,.35);font:600 14px/1.3 var(--ov-font,system-ui,sans-serif)';
++ const label = document.createElement('span');
++ label.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0';
++ label.textContent = 'Trying ' + t.name;
++ const btn = function (text, strong) {
++ const b = document.createElement('button');
++ b.type = 'button';
++ b.textContent = text;
++ b.style.cssText = 'flex:none;min-height:44px;padding:0 16px;border-radius:999px;cursor:pointer;font:inherit;' + (strong
++ ? 'border:0;background:var(--accent-strong,var(--accent,#3b82f6));color:var(--on-accent-strong,#fff)'
++ : 'border:1px solid var(--border-light,var(--border,#2c3d5c));background:transparent;color:inherit');
++ return b;
++ };
++ const use = btn('Use it', true);
++ const stop = btn('Stop', false);
++ use.addEventListener('click', function () {
++ stopTry(false);
++ save(t.id, t.vars);
++ const done = document.createElement('div');
++ done.setAttribute('role', 'status');
++ done.style.cssText = bar.style.cssText + ';padding:12px 18px';
++ done.textContent = t.name + ' is your theme now, on every OpenVibe site.';
++ document.body.appendChild(done);
++ setTimeout(function () { done.remove(); }, 3500);
++ });
++ stop.addEventListener('click', function () { stopTry(true); });
++ bar.append(label, use, stop);
++ document.body.appendChild(bar);
++ }
++
const activeId = resolveAndApply();
++ startTry(readTry());
// The icon link may be parsed after this script: repaint once the head is complete.
if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { _browserUiKey = ''; syncBrowserUi(); });
// A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.
@@@ -511,6 +617,8 @@
modeOf: function (id) { return MODES[id] || null; },
onColor: onColor,
save: save,
++ // 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,
resolveAndApply: resolveAndApply,
init: function (opts) {