openvibers/openvibe.shared Commits
4.41.0: try a theme on any OpenVibe page (?ov-try=), with Use it and Stop (#181)
Changes
@@@ -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.@@@ -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 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.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)",@@@ -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",@@@ -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": {nnew file mode 100644@@@ -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');@@@ -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) {
@@ -6,6 +6,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 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.
- **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.
@@ -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.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
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.40.0",
+ "version": "4.41.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.40.0",
+ "version": "4.41.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.40.0",
+ "version": "4.41.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.40.0",
+ "version": "4.41.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": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -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');
@@ -109,6 +109,7 @@
@@ -109,6 +109,7 @@
}
}
let _season = null;
let _season = null;
let _shown = { id: DEFAULT_ID, vars: null, custom: false };
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. */
/** Paint the theme `themeId` (its variables `vars`), or the season's theme in its place. → the id painted. */
// ── Light, dark and Auto ─────────────────────────────────
// ── Light, dark and Auto ─────────────────────────────────
// Every built-in theme has a light and a dark half (PAIRS above). The display preference `scheme` is 'manual' (the
// Every built-in theme has a light and a dark half (PAIRS above). The display preference `scheme` is 'manual' (the
@@ -133,6 +134,9 @@
@@ -133,6 +134,9 @@
function show(themeId, vars, custom) {
function show(themeId, vars, custom) {
_shown = { id: themeId, vars: vars, custom: !!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 half = halfFor(themeId, vars, !!custom);
const s = isDefault(half.id) && !half.custom && readDisplay().seasonal !== 'off' ? seasonNow() : null;
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;
const seasonTheme = s ? (modeOf(half.id) === 'light' && s.light ? s.light : s.theme) : null;
@@ -424,7 +428,109 @@
@@ -424,7 +428,109 @@
}
}
applyDisplay(readDisplay());
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();
const activeId = resolveAndApply();
+ startTry(readTry());
// The icon link may be parsed after this script: repaint once the head is complete.
// 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(); });
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.
// A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.
@@ -511,6 +617,8 @@
@@ -511,6 +617,8 @@
modeOf: function (id) { return MODES[id] || null; },
modeOf: function (id) { return MODES[id] || null; },
onColor: onColor,
onColor: onColor,
save: save,
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,
getCurrent: getCurrent,
resolveAndApply: resolveAndApply,
resolveAndApply: resolveAndApply,
init: function (opts) {
init: function (opts) {