openvibers/openvibe.shared Commits

4.64.0: one look everywhere, no flash; the Look panel in the account menu; Calm keeps the O (#216)

OP OpenVibers committed 13 days ago · 919e12369a · 14 files, +547 −135

Each domain kept its own undated copy of the look, so Live painted a stale one (seasonal still on after turning it off
on Network). Copies are dated and the newest wins against the account; links to other OpenVibe sites carry the look
(?ov_look=, OpenVibe referrers only, colours only); ov_paint plus look-boot paint before first frame where the loader
is deferred. The palette opens ov-look.js in place for members and guests. Calm hid the Pair's ring with the old comet.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8c7920c..b599763 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,31 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.64.0 — 2026-10-31
++
++- **One look, everywhere, with no flash** (plan L42; the owner: seasonal off on Network was still on, and orange, on
++ Live; "make it reliable and consistent… instant load… change the theme quickly from that user drop-down… works for
++ non logged in users"). Each OpenVibe domain keeps its own copy of the look; now:
++ - every copy is dated (`ov_display.at`, `ov_theme.at`), and the newest wins between this browser and the account.
++ An account copy without a date still wins, unless a change made here in the last minute may not have reached it;
++ when this browser's copy is newer, the account catches up with that date;
++ - a click on a link to another OpenVibe site carries the look (`?ov_look=`: what is painted, the display settings
++ and the theme, each dated). The next site adopts it when newer, before its first paint, and takes it out of the
++ address. It is read only after an OpenVibe page, so a link someone sends cannot set your look, and only colours
++ pass (no `url()`, no `expression()`). Guests get it too: nothing needs an account;
++ - theme-loader.js keeps what it painted in `ov_paint`, for pages whose loader comes late.
++- **`openvibe-shared/look-boot`**: the inline script for the top of `<head>` on a page whose theme-loader.js is deferred
++ (Live). It paints `ov_paint`, or a look a link just carried, before the first frame: colours, `data-theme` and the
++ `data-ov-*` display attributes, text size, background and `color-scheme`. A season whose dates are over is left for
++ the loader. `tag({ nonce })` gives the `<script>`; 1.6 KB.
++- **The Look panel** (`ov-look.js`, loaded the first time it opens, 2.5 KB brotli): the palette in the account menu,
++ and Look in a guest's menu, open it in place instead of sending people to openvibe.network/themes. Eleven themes and
++ the season's own, the season switch, where the look is kept (your account, or this browser), and "More looks on
++ openvibe.design", the home of every theme.
++- **Calm keeps the O.** Reduced motion hid `.ov-mark .o` along with the old comet; the Pair's ring shares that class,
++ so Calm took the O out of the mark. Only the icon's comet hides now.
++- theme-loader.js's size budget is 62,000 characters (15.45 KB brotli, was 15.41).
++
## 4.63.0 — 2026-10-31
- **37 more glyphs, for the Font Awesome names the network still used most** (plan L32 I4; openvibe.design/icons'
ddiff --git a/README.md b/README.md
iindex c4b9b69..5688c05 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.63.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.64.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 9d4f0a1..7d862fe 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.63.0",
++ "version": "4.64.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/files.js b/files.js
iindex 8270700..8af9ac7 100644
---- a/files.js
++++ b/files.js
@@@ -26,6 +26,7 @@ const BROWSER = Object.freeze([
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)
++ 'ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'history.js', // cross-site history
'sso-client.js', // link hand-off, FedCM
ddiff --git a/look-boot.js b/look-boot.js
nnew file mode 100644
iindex 0000000..961afd2
---- /dev/null
++++ b/look-boot.js
@@@ -0,0 +1,33 @@
++'use strict';
++/**
++ * openvibe-shared/look-boot — the look before first paint, for a page whose theme-loader.js loads late (deferred, as on
++ * Live). A tiny inline script: it paints the look this browser last painted on this site (theme-loader.js keeps it in
++ * localStorage 'ov_paint'), or the one a link from another OpenVibe page just carried (?ov_look=, read only after an
++ * OpenVibe page; theme-loader.js then keeps it and cleans the address). Colours only pass (no url(), no expression);
++ * a season whose dates are over is left for the loader. Plan L42, 4.64.0.
++ *
++ * const { BOOT, tag } = require('openvibe-shared/look-boot');
++ * `<head>${tag()}<link rel="stylesheet" …>` first in <head>, before any stylesheet
++ *
++ * The token order is theme-loader.js's own (THEME_KEYS), read from it here so the two cannot drift.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const LOADER = fs.readFileSync(path.join(__dirname, 'theme-loader.js'), 'utf8');
++const KEYS = JSON.parse(LOADER.match(/const THEME_KEYS = (\[[^\]]+\]);/)[1].replace(/'/g, '"'));
++
++const BOOT = `(function(){try{var d=document.documentElement,K=${JSON.stringify(KEYS)},P=null,ok=/^(#[0-9a-f]{3,8}|rgba?\\([0-9.,\\s%]+\\)|[0-9.,\\s]+|light|dark|(-?[0-9.]+(px)?\\s+){2,4}rgba?\\([0-9.,\\s%]+\\))$/i,m=/[?&]ov_look=([^&#]+)/.exec(location.search);`
++ + `if(m&&document.referrer&&/(^|\\.)(openvibe\\.[a-z]{2,12}|openre\\.stream)$/i.test(new URL(document.referrer).hostname)&&m[1].length<4000){var L=JSON.parse(atob(decodeURIComponent(m[1]).replace(/-/g,'+').replace(/_/g,'/')));if(L&&L.v===1)P=L.p}`
++ + `if(!P)P=JSON.parse(localStorage.getItem('ov_paint')||'null');if(!P||!P.k)return;var A=P.a||{};if(A['data-ov-season']&&P.u&&Date.now()>P.u)return;`
++ + `for(var i=0;i<K.length;i++){var v=String(P.k[i]||'');if(v&&ok.test(v))d.style.setProperty(K[i],v)}`
++ + `for(var n in A)if(/^data-(theme|theme-mode|ov-[a-z-]+)$/.test(n)&&/^[a-z0-9-]{1,40}$/.test(A[n]))d.setAttribute(n,A[n]);`
++ + `if(/^[0-9.]{1,5}$/.test(P.ts||''))d.style.setProperty('--ov-text-scale',P.ts);if(P.k[0]&&ok.test(P.k[0]))d.style.background=P.k[0];if(P.k[6]&&ok.test(P.k[6]))d.style.color=P.k[6];`
++ + `if(A['data-theme-mode']==='light'||A['data-theme-mode']==='dark')d.style.colorScheme=A['data-theme-mode']}catch(e){}})();`;
++
++/** The inline <script> for the top of <head> (a nonce when the page's CSP uses one). */
++function tag({ nonce = '' } = {}) {
++ return `<script${nonce ? ` nonce="${String(nonce).replace(/[^A-Za-z0-9+/=_-]/g, '')}"` : ''}>${BOOT}</script>`;
++}
++
++module.exports = { BOOT, tag, KEYS };
ddiff --git a/navbar.js b/navbar.js
iindex 715c87f..62d201d 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -390,6 +390,7 @@
.openvibe-navbar-dropdown-menu .ud-foot .ud-scheme .ud-ib[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
.openvibe-navbar-dropdown .ud-hbtn.ud-out { all: unset; box-sizing: border-box; cursor: pointer; display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); }
.openvibe-navbar-dropdown .ud-hbtn.ud-out:hover, .openvibe-navbar-dropdown .ud-hbtn.ud-out:focus-visible { color: var(--live-red-text, var(--live-red, #f87171)); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); }
++ .openvibe-navbar-dropdown-menu .ud-look, .ovnav-display .ud-look { flex-basis: 100%; }
.openvibe-navbar-dropdown-menu .ud-scheme { display: inline-flex; gap: 2px; padding: 2px; border-radius: 11px; background: var(--bg-tertiary, rgba(255,255,255,.06)); }
.openvibe-navbar-dropdown-menu .ud-scheme[hidden] { display: none; }
.openvibe-navbar-dropdown-menu .ud-scheme button { width: 32px; min-width: 0; height: 30px; padding: 0; justify-content: center; border-radius: 9px; color: var(--text-secondary, #a8b3c4); }
@@@ -475,7 +476,7 @@
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'd53438811552', 'ov-glyphs.js': '2b97058350a5', 'ov-brand.woff2': '3b4e6a8a5878' };
++ const SIBLING_REV = { 'ov-mark.js': 'd53438811552', 'ov-glyphs.js': '2b97058350a5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'f234846e81db' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@@ -805,12 +806,7 @@
const first = pop.querySelector('button'); if (first) first.focus();
});
}
-- /**
-- * ov-icons.js draws every .ov-icon in the launcher, the menu and the drawer. It used to load the first time one of them
-- * opened, so their icons popped in a moment later and the rows moved (owner, 2026-10-27). Now it loads once, quietly,
-- * after the page settles, and at once on intent (a pointer over the launcher, a touch, focus); the slots have their
-- * size meanwhile.
-- */
++ /** ov-icons.js draws every .ov-icon: loaded once after the page settles, or at once on intent; slots keep their size. */
let _iconsQueued = false;
function loadOvIcons(scope) {
if (root.OpenVibeIcons) { if (scope && root.OpenVibeIcons.mount) root.OpenVibeIcons.mount(scope); return; }
@@@ -870,7 +866,7 @@
const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd'));
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
if (burger && drawer) {
-- if (!_config.user) bindDisplayControls(drawer.querySelector('.ovnav-display')); // guests: members have them in the account menu
++ if (!_config.user) { bindDisplayControls(drawer.querySelector('.ovnav-display')); bindLook(drawer); } // guests: members have them in the account menu
burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer); });
drawer.addEventListener('click', (e) => {
const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return;
@@@ -907,6 +903,20 @@
box.hidden = false; paint();
}
++ /** The Look panel (ov-look.js, 4.64.0): the palette opens it in place; without the script the link goes to Design. */
++ function bindLook(scope) {
++ scope.addEventListener('click', (e) => {
++ const a = e.target.closest && e.target.closest('[data-ov-look-open]'); if (!a) return;
++ const box = a.parentElement && a.parentElement.parentElement && a.parentElement.parentElement.querySelector('.ud-look') || scope.querySelector('.ud-look'); if (!box) return;
++ e.preventDefault(); e.stopPropagation();
++ const open = box.hidden; box.hidden = !open; a.setAttribute('aria-expanded', String(open));
++ if (!open || box.__ovlk) return;
++ const go = () => { if (root.OVLook) root.OVLook.mount(box, { signedIn: !!_config.user, season: !!_config.user }); else location.href = a.href; };
++ if (root.OVLook) return go();
++ const sc = document.createElement('script'); sc.src = siblingV('ov-look.js'); sc.async = true; sc.onload = go; sc.onerror = () => { location.href = a.href; }; document.head.appendChild(sc);
++ });
++ }
++
/** Display rows (text size, animations) cycle their value; state lives in theme-loader.js. */
function bindDisplayRows(dropdown) {
bindScheme(dropdown);
@@@ -968,7 +978,7 @@
const sch = L.scheme ? L.scheme.get() : null;
const ss = (val, label, title) => `<button type="button" data-s="${val}" aria-pressed="${sch === val}" title="${title}">${label}</button>`;
box.innerHTML = `<span class="ovnav-dl">Display</span>${sch ? `<span class="ovnav-seg" role="group" aria-label="Light or dark">${ss('light', 'Light', 'Light theme')}${ss('dark', 'Dark', 'Dark theme')}${ss('auto', 'Auto', 'Follow this device')}</span>` : ''}<span class="ovnav-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>
-- <span class="ovnav-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span>${seasonState() ? `<span class="ovnav-seg" role="group" aria-label="Seasonal themes">${seg('seasonal', 'on', seasonState().s.name, 'Seasonal themes on')}${seg('seasonal', 'off', 'Off', 'Seasonal themes off')}</span>` : ''}<a href="https://openvibe.network/themes">Themes</a>`;
++ <span class="ovnav-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span>${seasonState() ? `<span class="ovnav-seg" role="group" aria-label="Seasonal themes">${seg('seasonal', 'on', seasonState().s.name, 'Seasonal themes on')}${seg('seasonal', 'off', 'Off', 'Seasonal themes off')}</span>` : ''}<a href="https://openvibe.design/look" data-ov-look-open aria-expanded="false">Look</a><div class="ud-look" hidden></div>`;
box.hidden = false;
};
box.addEventListener('click', (e) => {
@@@ -1238,11 +1248,7 @@
function resolveLoginHref(host, returnUrl) {
if (_config.loginUrl) return fillUrl(_config.loginUrl, returnUrl);
if (onToolsDomain(host)) {
-- // Every *.openvibe.tools host signs in through the gateway apex: it
-- // holds the OAuth state cookie + redirect_uri (both apex-host-only)
-- // and sets ov_token with Domain=.openvibe.tools, so the session
-- // reaches all satellites. A host-local /auth/login would 404 on
-- // satellites and break the OAuth state check on gateway subdomains.
++ // *.openvibe.tools signs in through the gateway apex (its OAuth state; ov_token on Domain=.openvibe.tools).
return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;
}
if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
@@@ -1381,12 +1387,7 @@
try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }
}
-- /**
-- * One silent sign-in attempt per tab: only when this browser has signed in to the network
-- * before (the hint survives token expiry), never after an explicit sign-out ('guest'), and
-- * never for bots. The site's login route turns silent=1 into prompt=none and comes straight
-- * back on error=login_required, so a signed-out visitor sees one quick redirect at most.
-- */
++ /** One silent sign-in per tab, only after an earlier sign-in here (never after 'guest', never for bots): silent=1. */
let _ssoClientLoading = null;
function loadSsoClient() {
if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
@@@ -1747,8 +1748,9 @@
<div class="ud-scheme" role="group" aria-label="Light or dark" hidden>${iconBtn('', navIcon('fa-sun'), 'Light', ' data-ov-scheme="light" aria-pressed="false"')}${iconBtn('', navIcon('fa-moon'), 'Dark', ' data-ov-scheme="dark" aria-pressed="false"')}${iconBtn('', navIcon('fa-circle-half-stroke'), 'Auto: follow this device', ' data-ov-scheme="auto" aria-pressed="false"')}</div>
<button type="button" class="ud-ib ud-txt" data-ov-display="text" title="Text size" aria-label="Text size"><span class="icon">${navIcon('fa-text-height')}</span><b class="ud-val"></b></button>
<button type="button" class="ud-ib" data-ov-display="motion" title="Animations" aria-label="Animations"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span><b class="ud-val"></b></button>
-- <a class="ud-ib" href="https://openvibe.network/themes" title="Themes: every theme, and the season's own" aria-label="Themes"><span class="icon">${navIcon('fa-palette')}</span></a>
++ <a class="ud-ib" href="https://openvibe.design/look" data-ov-look-open aria-expanded="false" title="Look: themes, the season, and more on openvibe.design" aria-label="Look"><span class="icon">${navIcon('fa-palette')}</span></a>
</div>
++ <div class="ud-look" hidden></div>
</div>
`;
dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });
@@@ -1758,6 +1760,7 @@
bindChips(dropdown);
if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; const path = sitePath(a); if (path != null) { if (_config.onNavigate(path, e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
bindDisplayRows(dropdown);
++ bindLook(dropdown);
regPanel(dropdown, 'user-menu');
// Avatar click toggles dropdown (the selection for Ask Vibe is read on pointerdown: the press can clear it)
@@@ -2004,11 +2007,7 @@
const el = render();
upgradeIconsWhenFontsReady();
if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
-- // Pages that hand us a resolved user (openvibe.network, the tools
-- // gateway hub) keep full control. Everyone else — pages that pass
-- // only a token, or nothing at all — gets the user resolved from
-- // the shared SSO state (ov_token cookie / localStorage / optional
-- // sessionUrl) and the navbar re-renders when it arrives.
++ // A page that passes a user keeps control; otherwise the user comes from the shared SSO state, then a re-render.
// auth: 'external' — the site signs people in itself (Live) and tells us with setUser().
if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
else if (!_config.user) refreshAuthState();
ddiff --git a/ov-look.js b/ov-look.js
nnew file mode 100644
iindex 0000000..05a564d
---- /dev/null
++++ b/ov-look.js
@@@ -0,0 +1,99 @@
++/**
++ * openvibe-shared/ov-look.js — the Look panel (plan L42, 4.64.0): change your look from any OpenVibe site, signed in
++ * or not, without leaving it. navbar.js loads it from beside itself the first time someone opens Look (the palette in
++ * the account menu, or Look in a guest's menu), so it costs nothing on first paint.
++ *
++ * OVLook.mount(box, { signedIn, season }) draws the panel into `box` and keeps it current (ov:theme, ov:display);
++ * season: false leaves out the season switch where the host has its own
++ *
++ * Theme swatches (the season's own while there is one), the season switch, where this look comes from, and
++ * "More looks" on openvibe.design, the home of every theme. State is theme-loader.js's: a pick applies at once, is
++ * kept in this browser, saved to the account when signed in, and travels with links to the other OpenVibe sites.
++ */
++(function (root) {
++ 'use strict';
++ if (!root || root.OVLook) return;
++ const DESIGN = 'https://openvibe.design';
++ const PICKS = [['vibe', 'Vibe'], ['midnight', 'Midnight'], ['violet-hour', 'Violet'], ['nord', 'Nord'], ['dracula', 'Dracula'], ['ocean-deep', 'Ocean'],
++ ['forest', 'Forest'], ['sunset', 'Sunset'], ['high-contrast', 'Contrast'], ['daylight', 'Daylight'], ['paper', 'Paper']];
++ const CSS = '.ovlk{container-type:inline-size;display:grid;gap:10px;padding:12px 4px 4px;font-size:13px;color:var(--text-secondary,#a8b3c4)}'
++ + '.ovlk-h{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.ovlk-h b{color:var(--text-primary,#e6edf7);font-size:13.5px}.ovlk-h span{font-size:11.5px;color:var(--text-muted,#7d8aa0)}'
++ + '.ovlk-sw{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:6px}@container (max-width:330px){.ovlk-sw{grid-template-columns:repeat(4,minmax(0,1fr))}}'
++ + '.ovlk .ovlk-sw button{all:unset;box-sizing:border-box;display:grid;justify-items:center;gap:4px;padding:6px 0;border-radius:12px;cursor:pointer;border:1px solid transparent}'
++ + '.ovlk-sw button:hover{background:var(--bg-hover,rgba(255,255,255,.06))}.ovlk-sw button:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:1px}'
++ + '.ovlk-sw button[aria-checked=true]{border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}'
++ + '.ovlk-dot{position:relative;width:28px;height:28px;border-radius:50%;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(127,127,127,.35)}.ovlk-dot i{position:absolute;inset:0 0 0 50%}'
++ + '.ovlk-sw small{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px;letter-spacing:-.02em;color:var(--text-muted,#7d8aa0)}.ovlk-sw button[aria-checked=true] small{color:var(--text-primary,#e6edf7)}'
++ + '.ovlk .ovlk-season{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-radius:12px;background:var(--bg-tertiary,rgba(255,255,255,.05))}'
++ + '.ovlk-season b{color:var(--text-primary,#e6edf7);font-weight:650}'
++ + '.ovlk .ovlk-switch{all:unset;position:relative;display:block;width:38px!important;height:22px!important;padding:0!important;flex:none;border-radius:999px;background:var(--border,#2c3d5c);cursor:pointer}.ovlk-switch::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;transition:transform .15s}'
++ + '.ovlk .ovlk-switch[aria-checked=true]{background:var(--accent-strong,var(--accent,#3472d8))}.ovlk .ovlk-switch[aria-checked=true]::after{transform:translateX(16px)}.ovlk .ovlk-switch:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}'
++ + '.ovlk-why{font-size:11.5px;color:var(--text-muted,#7d8aa0)}'
++ + '.ovlk a.ovlk-more{display:flex!important;align-items:center;justify-content:space-between;gap:8px;padding:9px 10px!important;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text-primary,#e6edf7)!important;text-decoration:none;font-size:13px!important;font-weight:600!important;line-height:1.35}'
++ + '.ovlk-more:hover{border-color:var(--accent,#3b82f6)}.ovlk-more span{color:var(--accent-light,var(--accent,#60a5fa))}'
++ + '@media (prefers-reduced-motion:reduce){.ovlk-switch::after{transition:none}}';
++
++ function css() {
++ if (document.getElementById('ov-look-css')) return;
++ const st = document.createElement('style'); st.id = 'ov-look-css'; st.textContent = CSS; document.head.appendChild(st);
++ }
++ const el = (tag, attrs, kids) => {
++ const n = document.createElement(tag);
++ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
++ (kids || []).forEach((c) => n.append(c));
++ return n;
++ };
++ function swatch(id, name, vars, checked) {
++ const dot = el('span', { class: 'ovlk-dot', 'aria-hidden': 'true' }, [el('i')]);
++ dot.style.background = vars['--bg-primary'] || '#0a0f1c';
++ dot.firstChild.style.background = vars['--accent'] || '#3b82f6';
++ return el('button', { type: 'button', role: 'radio', 'aria-checked': String(checked), 'data-look': id, title: name }, [dot, el('small', {}, [name])]);
++ }
++
++ function draw(box, opts) {
++ const L = root.OpenVibeThemeLoader;
++ box.textContent = '';
++ if (!L || !L.THEMES) { box.append(el('a', { class: 'ovlk-more', href: `${DESIGN}/look` }, ['Your look on openvibe.design', el('span', {}, ['\u2192'])])); return; }
++ const season = L.seasons && L.seasons.now();
++ const showing = L.seasons && L.seasons.showing();
++ const cur = showing ? `season:${showing.id}` : L.getCurrent();
++ const panel = el('div', { class: 'ovlk' });
++ panel.append(el('div', { class: 'ovlk-h' }, [el('b', {}, ['Look']), el('span', {}, ['Follows you to every OpenVibe site'])]));
++ const sw = el('div', { class: 'ovlk-sw', role: 'radiogroup', 'aria-label': 'Theme' });
++ if (season && L.THEMES[season.theme]) sw.append(swatch(`season:${season.id}`, season.name, L.THEMES[season.theme], cur === `season:${season.id}`));
++ PICKS.forEach(([id, name]) => { if (L.THEMES[id]) sw.append(swatch(id, name, L.THEMES[id], cur === id)); });
++ panel.append(sw);
++ if (season && opts.season !== false) {
++ const on = L.display.get().seasonal !== 'off';
++ panel.append(el('div', { class: 'ovlk-season' }, [el('span', {}, [el('b', {}, [`${season.name} theme`]), ` on the default look`]),
++ el('button', { type: 'button', class: 'ovlk-switch', role: 'switch', 'aria-checked': String(on), 'aria-label': `${season.name} theme`, 'data-look-season': '' })]));
++ }
++ const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());
++ const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);
++ panel.append(el('div', { class: 'ovlk-why' }, [`${named} \u00b7 ${signedIn ? 'saved to your account' : 'saved in this browser'}`]));
++ panel.append(el('a', { class: 'ovlk-more', href: `${DESIGN}/` }, ['More looks on openvibe.design', el('span', {}, ['\u2192'])]));
++ box.append(panel);
++ }
++
++ function mount(box, opts) {
++ if (!box || box.__ovlk) return;
++ box.__ovlk = true;
++ opts = opts || {};
++ css();
++ draw(box, opts);
++ box.addEventListener('click', (e) => {
++ const L = root.OpenVibeThemeLoader; if (!L) return;
++ const s = e.target.closest && e.target.closest('[data-look-season]');
++ if (s) { e.stopPropagation(); L.display.set({ seasonal: s.getAttribute('aria-checked') === 'true' ? 'off' : 'on' }); return draw(box, opts); }
++ const b = e.target.closest && e.target.closest('[data-look]'); if (!b) return;
++ e.stopPropagation();
++ const id = b.getAttribute('data-look');
++ if (id.indexOf('season:') === 0) { L.display.set({ seasonal: 'on' }); L.save('vibe', null, { keepSeasonal: true }); }
++ else L.save(id);
++ draw(box, opts);
++ });
++ ['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, () => { if (box.isConnected) draw(box, opts); }));
++ }
++
++ root.OVLook = { mount, PICKS };
++})(typeof window !== 'undefined' ? window : this);
ddiff --git a/package.json b/package.json
iindex 53da812..f9f7ed6 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.63.0",
++ "version": "4.64.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -51,6 +51,8 @@
"./openvibe-sw": "./openvibe-sw.js",
"./ov-icons": "./ov-icons.js",
"./ov-glyphs": "./ov-glyphs.js",
++ "./ov-look": "./ov-look.js",
++ "./look-boot": "./look-boot.js",
"./ov-mark": "./ov-mark.js",
"./package.json": "./package.json",
"./panels": "./panels.js",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex da4bbaf..76a8b99 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -9,7 +9,7 @@ const path = require('path');
const { hashAsset } = require('../assets');
const ROOT = path.join(__dirname, '..');
--const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2'];
++const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js'];
const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
ddiff --git a/test/look-boot.test.js b/test/look-boot.test.js
nnew file mode 100644
iindex 0000000..91a5491
---- /dev/null
++++ b/test/look-boot.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++// The look before first paint (4.64.0, plan L42): look-boot's inline script paints what this browser last painted here
++// (ov_paint) or what a link from another OpenVibe page carried (?ov_look=), colours only, and leaves an ended season
++// to the loader. The Look panel (ov-look.js) offers only themes the loader has, and the navbar opens it in place.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { BOOT, tag, KEYS } = require('../look-boot');
++
++const ROOT = path.join(__dirname, '..');
++const NOW = new Date(2026, 10, 5, 12).getTime();
++
++function boot({ store = {}, search = '', referrer = '' } = {}) {
++ const attrs = {}; const props = {}; const style = { setProperty: (k, v) => { props[k] = v; } };
++ const html = { style, setAttribute: (k, v) => { attrs[k] = String(v); } };
++ const sandbox = {
++ document: { documentElement: html, referrer },
++ location: { search },
++ localStorage: { getItem: (k) => (k in store ? store[k] : null) },
++ atob: (s) => Buffer.from(s, 'base64').toString('binary'),
++ URL, JSON, Date: { now: () => NOW }, String,
++ };
++ vm.runInNewContext(BOOT, sandbox);
++ return { attrs, props, style };
++}
++const paint = (k, a = {}, extra = {}) => JSON.stringify({ k, a, ts: '', at: NOW - 1000, ...extra });
++const nord = ['#2e3440', '#3b4252', '#434c5e', '#4c566a', '#3b4252', '#2e3440', '#eceff4', '#d8dee9', '#a3acbd', '#88c0d0', '#8fbcbb', '#5e81ac'];
++const token = (p) => Buffer.from(JSON.stringify({ v: 1, d: {}, da: 0, t: 'nord', ta: NOW, p }), 'binary').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
++
++// ── The order is theme-loader's own ──
++const loader = fs.readFileSync(path.join(ROOT, 'theme-loader.js'), 'utf8');
++assert.ok(KEYS.length > 20 && KEYS[0] === '--bg-primary', 'the token order comes from theme-loader.js');
++assert.ok(loader.includes(`const THEME_KEYS = ${JSON.stringify(KEYS).replace(/"/g, "'").replace(/,/g, ', ')}`) || KEYS.every((k) => loader.includes(`'${k}'`)), 'every key is the loader\'s');
++assert.ok(BOOT.length < 2200, `small enough to inline (${BOOT.length} chars)`);
++
++// ── Paints what this browser last painted here ──
++let r = boot({ store: { ov_paint: paint(nord, { 'data-theme': 'nord', 'data-theme-mode': 'dark', 'data-ov-motion': 'reduced' }) } });
++assert.strictEqual(r.props['--bg-primary'], '#2e3440');
++assert.strictEqual(r.props[KEYS[9]], '#88c0d0');
++assert.strictEqual(r.attrs['data-theme'], 'nord');
++assert.strictEqual(r.attrs['data-ov-motion'], 'reduced', 'Calm before first paint');
++assert.strictEqual(r.style.background, '#2e3440', 'the page background, so nothing flashes');
++assert.strictEqual(r.style.colorScheme, 'dark');
++
++// ── Nothing kept: nothing painted, nothing thrown ──
++r = boot();
++assert.deepStrictEqual([Object.keys(r.props).length, Object.keys(r.attrs).length], [0, 0]);
++r = boot({ store: { ov_paint: '{not json' } });
++assert.strictEqual(Object.keys(r.props).length, 0, 'a broken record is ignored');
++
++// ── A season whose dates are over is left for the loader ──
++r = boot({ store: { ov_paint: paint(nord, { 'data-ov-season': 'halloween' }, { u: NOW - 1 }) } });
++assert.strictEqual(Object.keys(r.props).length, 0);
++r = boot({ store: { ov_paint: paint(nord, { 'data-ov-season': 'halloween' }, { u: NOW + 86400000 }) } });
++assert.strictEqual(r.attrs['data-ov-season'], 'halloween', 'a running season paints');
++
++// ── Only colours and known attributes pass ──
++const bad = nord.slice(); bad[0] = 'url(https://evil.example/x.png)'; bad[6] = 'expression(alert(1))';
++r = boot({ store: { ov_paint: paint(bad, { 'data-theme': 'x"><script>', onload: 'alert(1)', 'data-ov-text': 'lg' }) } });
++assert.ok(!('--bg-primary' in r.props) && !r.style.background && !r.style.color, 'url() and expression() never reach the page');
++assert.strictEqual(r.props['--bg-secondary'], '#3b4252', 'the colours beside them do');
++assert.ok(!('data-theme' in r.attrs) && !('onload' in r.attrs), 'odd attributes and odd values are dropped');
++assert.strictEqual(r.attrs['data-ov-text'], 'lg');
++
++// ── A link from another OpenVibe page wins; from anywhere else it is ignored ──
++const arriving = token({ k: ['#101010'].concat(nord.slice(1)), a: { 'data-theme': 'midnight' } });
++r = boot({ store: { ov_paint: paint(nord, { 'data-theme': 'nord' }) }, search: `?tab=x&ov_look=${arriving}`, referrer: 'https://openvibe.network/themes' });
++assert.deepStrictEqual([r.props['--bg-primary'], r.attrs['data-theme']], ['#101010', 'midnight'], 'the carried look paints first');
++r = boot({ store: { ov_paint: paint(nord, { 'data-theme': 'nord' }) }, search: `?ov_look=${arriving}`, referrer: 'https://evil.example/' });
++assert.deepStrictEqual([r.props['--bg-primary'], r.attrs['data-theme']], ['#2e3440', 'nord'], 'a link from elsewhere cannot set the look');
++r = boot({ store: {}, search: `?ov_look=${arriving}`, referrer: 'https://openvibe.network.evil.example/' });
++assert.strictEqual(Object.keys(r.props).length, 0, 'a look-alike host is elsewhere');
++
++// ── The tag ──
++assert.ok(tag().startsWith('<script>(function(){') && tag().endsWith('</script>'));
++assert.ok(tag({ nonce: 'abc123' }).startsWith('<script nonce="abc123">'));
++assert.ok(!tag({ nonce: '"><x' }).includes('"><x'), 'a nonce cannot break out of its attribute');
++
++// ── The Look panel offers only real themes, and the navbar opens it in place ──
++const sandbox = { window: {} };
++sandbox.window.window = sandbox.window;
++vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'ov-look.js'), 'utf8'), sandbox);
++const { PICKS } = sandbox.window.OVLook;
++assert.ok(PICKS.length >= 8 && PICKS.every(([id]) => /^[a-z-]+$/.test(id)), 'picks are theme ids');
++const loaderTheme = (id) => loader.includes(`\n '${id}': ['`);
++assert.ok(PICKS.every(([id]) => loaderTheme(id)), `every pick is one of theme-loader's themes: ${PICKS.filter(([id]) => !loaderTheme(id)).map(([id]) => id)}`);
++assert.ok(!fs.readFileSync(path.join(__dirname, '..', 'ov-look.js'), 'utf8').split('\n').some((l) => !/^\s*(\/\*\*|\*)/.test(l) && /[^\x00-\x7f]/.test(l)), 'its text is ASCII (escapes), so a server without a charset still shows it right');
++const nav = fs.readFileSync(path.join(ROOT, 'navbar.js'), 'utf8');
++assert.ok(/data-ov-look-open/.test(nav) && nav.includes("siblingV('ov-look.js')"), 'the navbar loads the panel from beside itself');
++assert.ok(!nav.includes('openvibe.network/themes"'), 'the menu no longer sends people to another site to change their look');
++assert.ok(require('../files').BROWSER.includes('ov-look.js'), 'served');
++
++console.log(`look boot: paints ov_paint or a carried look before first paint (${BOOT.length} chars), colours only, ended seasons left; the Look panel's ${PICKS.length} picks are real themes`);
ddiff --git a/test/look-travel.test.js b/test/look-travel.test.js
nnew file mode 100644
iindex 0000000..35bc460
---- /dev/null
++++ b/test/look-travel.test.js
@@@ -0,0 +1,132 @@
++'use strict';
++// One look, everywhere (4.64.0, plan L42). Each OpenVibe domain keeps its own copy of the look, so:
++// - every copy is dated, and the newest wins between this browser and the account (an account without dates still
++// wins, unless a change made here in the last minute may not have reached it);
++// - a click on a link to another OpenVibe site carries the look (?ov_look=: what is painted, the display settings and
++// the theme, each dated); the next site adopts it when newer, before its first paint, and takes it out of the
++// address; it is read only after an OpenVibe page, and only colours pass;
++// - Calm no longer hides the mark's O (the Pair's ring shares the old comet's class).
++// theme-loader.js runs here in a small fake browser.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');
++const NOW = new Date(2026, 10, 5, 12).getTime(); // 5 November: no season
++
++function load({ store = new Map(), href = 'https://openvibe.live/', referrer = '', active = null, now = NOW, cookie = 'ov_sso_hint=account' } = {}) {
++ class FixedDate extends Date { constructor(...a) { if (a.length) super(...a); else super(now); } static now() { return now; } }
++ const attrs = new Map(); const props = new Map(); const puts = []; const docListeners = {}; const replaced = [];
++ const html = {
++ style: { setProperty: (k, v) => props.set(k, v), removeProperty: (k) => props.delete(k), getPropertyValue: (k) => props.get(k) || '' },
++ setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k),
++ attrs, props,
++ };
++ const u = new URL(href);
++ const sandbox = {
++ Date: FixedDate, JSON, Object, String, Number, Math, Array, Promise, Error, URL, URLSearchParams,
++ atob: (s) => Buffer.from(s, 'base64').toString('binary'), btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
++ document: { documentElement: html, readyState: 'complete', cookie, head: null, querySelector: () => null, getElementById: () => null, referrer, visibilityState: 'visible',
++ addEventListener: (type, fn) => { (docListeners[type] = docListeners[type] || []).push(fn); } },
++ setTimeout: () => 0, clearTimeout() {},
++ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
++ location: { hostname: u.hostname, protocol: u.protocol, href: u.href, search: u.search, pathname: u.pathname, hash: u.hash },
++ history: { state: null, replaceState: (_s, _t, url) => replaced.push(url) },
++ getComputedStyle: () => ({ getPropertyValue: () => '' }),
++ fetch: (url, o) => {
++ if (o && o.method === 'PUT') { puts.push({ url, body: JSON.parse(o.body) }); return Promise.resolve({ ok: true, json: () => Promise.resolve(null) }); }
++ return Promise.resolve(active ? { ok: true, json: () => Promise.resolve(active) } : { 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);
++ const click = (linkHref) => {
++ const a = { href: linkHref, hasAttribute: () => false, closest: () => a };
++ (docListeners.pointerdown || []).forEach((fn) => fn({ target: a }));
++ return a.href;
++ };
++ return { L: sandbox.OpenVibeThemeLoader, html, puts, store, replaced, click };
++}
++const settle = () => new Promise((r) => setImmediate(r));
++const display = (store) => JSON.parse(store.get('ov_display') || 'null');
++
++(async () => {
++ // ── Calm no longer hides the O ──
++ assert.ok(!SRC.includes('.ov-mark .o,') && !/data-ov-motion=reduced\] \.ov-mark \.o\{display:none/.test(SRC), 'Calm keeps the mark whole');
++
++ // ── A change made here is dated, and the date goes to the account ──
++ let p = load();
++ p.L.display.set({ seasonal: 'off' });
++ assert.strictEqual(display(p.store).seasonal, 'off');
++ assert.strictEqual(display(p.store).at, NOW, 'dated now');
++
++ // ── The account's answer: newest wins ──
++ const older = new Map([['ov_display', JSON.stringify({ seasonal: 'off', at: NOW - 5000 })]]);
++ p = load({ store: older, active: { theme_id: 'vibe', display: { seasonal: 'on' }, display_at: NOW - 60000 } });
++ await settle(); await settle();
++ assert.strictEqual(display(p.store).seasonal, 'off', 'an older account copy does not undo a newer change here');
++ assert.ok(p.puts.some((x) => /me\/display$/.test(x.url) && x.body.seasonal === 'off' && x.body.at === NOW - 5000), 'and the account catches up, with this copy\'s date');
++
++ const newer = new Map([['ov_display', JSON.stringify({ seasonal: 'on', at: NOW - 600000 })]]);
++ p = load({ store: newer, active: { theme_id: 'vibe', display: { seasonal: 'off' }, display_at: NOW - 1000 } });
++ await settle(); await settle();
++ assert.strictEqual(display(p.store).seasonal, 'off', 'a newer account copy wins');
++ assert.strictEqual(display(p.store).at, NOW - 1000, 'and keeps its date');
++
++ const undated = new Map([['ov_display', JSON.stringify({ seasonal: 'on', at: NOW - 600000 })]]);
++ p = load({ store: undated, active: { theme_id: 'vibe', display: { seasonal: 'off' } } });
++ await settle(); await settle();
++ assert.strictEqual(display(p.store).seasonal, 'off', 'an account without dates still wins over an old copy');
++ const fresh = new Map([['ov_display', JSON.stringify({ seasonal: 'on', at: NOW - 10000 })]]);
++ p = load({ store: fresh, active: { theme_id: 'vibe', display: { seasonal: 'off' } } });
++ await settle(); await settle();
++ assert.strictEqual(display(p.store).seasonal, 'on', '…but not over a change made here in the last minute');
++
++ // ── The look travels with a link to another OpenVibe site, and only there ──
++ const home = new Map([['ov_display', JSON.stringify({ seasonal: 'off', motion: 'reduced', at: NOW - 2000 })], ['ov_theme', JSON.stringify({ id: 'nord', variables: { '--bg-primary': '#2e3440' }, at: NOW - 3000 })]]);
++ p = load({ store: home, href: 'https://openvibe.network/themes' });
++ const out = p.click('https://openvibe.live/@goosely?tab=clips#top');
++ const ou = new URL(out);
++ assert.ok(ou.searchParams.get('ov_look'), 'the link to Live carries the look');
++ assert.strictEqual(ou.searchParams.get('tab'), 'clips', 'its own query is kept');
++ assert.strictEqual(ou.hash, '#top', 'and its anchor');
++ assert.strictEqual(p.click('https://openvibe.network/my'), 'https://openvibe.network/my', 'a link on the same site is untouched');
++ assert.strictEqual(p.click('https://example.com/'), 'https://example.com/', 'a link off the network is untouched');
++ const token = ou.searchParams.get('ov_look');
++ const rec = JSON.parse(Buffer.from(token.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('binary'));
++ assert.deepStrictEqual([rec.v, rec.d.seasonal, rec.d.motion, rec.da, rec.t, rec.ta], [1, 'off', 'reduced', NOW - 2000, 'nord', NOW - 3000]);
++ assert.ok(Array.isArray(rec.p.k) && rec.p.k.length > 20, 'with what is painted');
++
++ // Live, with an old copy of its own: it adopts the newer look before first paint, and the address is clean again.
++ const liveOld = new Map([['ov_display', JSON.stringify({ seasonal: 'on', at: NOW - 86400000 })]]);
++ p = load({ store: liveOld, href: out, referrer: 'https://openvibe.network/themes' });
++ assert.strictEqual(display(p.store).seasonal, 'off', 'seasonal off arrived');
++ assert.strictEqual(display(p.store).motion, 'reduced', 'Calm arrived');
++ assert.strictEqual(p.html.attrs.get('data-ov-motion'), 'reduced', 'painted before anything else');
++ assert.strictEqual(JSON.parse(p.store.get('ov_theme')).id, 'nord', 'the theme arrived');
++ assert.strictEqual(p.html.attrs.get('data-theme'), 'nord');
++ assert.deepStrictEqual(p.replaced, ['/@goosely?tab=clips#top'], 'ov_look leaves the address');
++
++ // A newer copy here is not overwritten by an older look arriving.
++ const liveNew = new Map([['ov_display', JSON.stringify({ seasonal: 'on', at: NOW - 100 })]]);
++ p = load({ store: liveNew, href: out, referrer: 'https://openvibe.network/' });
++ assert.strictEqual(display(p.store).seasonal, 'on', 'the newer copy stays');
++
++ // Not from an OpenVibe page: ignored (a link someone sends cannot set your look), and still taken out of the address.
++ p = load({ store: new Map(), href: out, referrer: 'https://evil.example/' });
++ assert.strictEqual(p.store.get('ov_display'), undefined);
++ assert.strictEqual(p.replaced.length, 1);
++
++ // Only colours pass: a crafted custom theme with a url() is dropped value by value.
++ const crafted = { v: 1, d: {}, da: 0, t: 'mine', c: 1, ta: NOW - 10, p: { k: ['url(https://evil.example/x.png)', '#101828', '#172238', '#131c2e', '#1c2a44', '#0d1424', '#e6edf7', '#96a7c2', '#8192ac', '#3b82f6', '#60a5fa', '#1d4ed8', 'expression(alert(1))'] } };
++ const evil = `https://openvibe.live/?ov_look=${Buffer.from(JSON.stringify(crafted), 'binary').toString('base64')}`;
++ p = load({ store: new Map(), href: evil, referrer: 'https://openvibe.network/' });
++ const vars = JSON.parse(p.store.get('ov_theme')).variables;
++ assert.ok(!Object.values(vars).some((v) => /url|expression/.test(v)), 'no url() or expression() in a stored theme');
++ assert.strictEqual(vars['--bg-secondary'], '#101828', 'the colours pass');
++
++ console.log('look travel: Calm keeps the O; dated copies, newest wins both ways; the look rides OpenVibe links, adopted before first paint when newer, only from OpenVibe pages, only colours');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex 5c09801..0f29dcb 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -237,7 +237,7 @@ function load(hostname, opts = {}) {
// 4.23.0 (the owner: "a plus button… better use of icons… compactness"): + and the gear beside the name, your places
// as a grid of icons, and one row at the foot for light/dark, text size, motion and themes; Sign out by the gear.
assert.ok(/class="ud-hbtn add-account" id="openvibe-add-account"[^>]*aria-label="Add another account"/.test(dd.innerHTML), 'Add another account is a + beside the name');
-- assert.ok(dd.innerHTML.includes('aria-label="Account &amp; settings"') && dd.innerHTML.includes('aria-label="Themes"') && /id="openvibe-logout-btn"[^>]*aria-label="Sign out"/.test(dd.innerHTML), 'the gear, Themes and Sign out are icon buttons');
++ assert.ok(dd.innerHTML.includes('aria-label="Account &amp; settings"') && dd.innerHTML.includes('aria-label="Look"') && dd.innerHTML.includes('data-ov-look-open') && /id="openvibe-logout-btn"[^>]*aria-label="Sign out"/.test(dd.innerHTML), 'the gear, Look (4.64.0: opens in place) and Sign out are icon buttons');
assert.ok(/<div class="ud-grid">/.test(dd.innerHTML) && /class="ud-tile"[^>]*data-menu-id="mp"/.test(dd.innerHTML), 'the places are a grid of tiles, the site\'s own first');
assert.ok(!dd.innerHTML.includes('Recently used'), 'the places you went are the search menu\'s');
assert.ok(!dd.innerHTML.includes('Across OpenVibe'), 'the other sites are the launcher\'s and the drawer\'s, not the account menu\'s');
ddiff --git a/test/theme-parity.test.js b/test/theme-parity.test.js
iindex 1a244f3..0bf771b 100644
---- a/test/theme-parity.test.js
++++ b/test/theme-parity.test.js
@@@ -48,7 +48,8 @@ for (const t of BUILTIN_THEMES) {
for (const k of Object.keys(want)) assert.strictEqual(LT[t.id][k], String(want[k]), `loader's ${t.id} carries ${k} as the catalog has it`);
}
assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');
--assert.ok(loader.length < 60000, `theme-loader.js stays small for first paint (${loader.length} bytes)`);
++// 62,000 since 4.64.0: the look travels between sites (?ov_look=) and every copy is dated; brotli 15.45 KB (was 15.41) (2026-10-31).
++assert.ok(loader.length < 62000, `theme-loader.js stays small for first paint (${loader.length} characters)`);
assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');
// 5. Derived tokens: text on the accent always reads, on every theme.
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 53ac2a3..ae7a5eb 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -1,19 +1,11 @@
--// ═══════════════════════════════════════════════════════════════
--// OpenVibe — Theme Loader (Browser)
--// Synchronous theme application from cookie/localStorage.
--// Load this BEFORE navbar.js to prevent FOUC.
--// Sets CSS custom properties on <html> from the user's theme.
--// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Theme Loader (browser): the person's look on <html> before first paint. Load it before navbar.js.
(function (root) {
'use strict';
const COOKIE_NAME = 'ov_theme_id';
const STORAGE_KEY = 'ov_theme';
-- // The user's theme is owned centrally by openvibe.network, so EVERY app (openvibe.live,
-- // openvibe.tools + subdomains, openvibe.games, openvibe.media) fetches it from there —
-- // that's what makes the theme chosen at openvibe.network/themes sync everywhere.
-- // Apps may override via init({apiBase}).
++ // The account's look lives on openvibe.network for every site (init({ apiBase }) overrides it).
const DEFAULT_API_BASE = 'https://openvibe.network';
let _config = { apiBase: DEFAULT_API_BASE };
@@@ -90,13 +82,8 @@
const MODES = { 'vibe':'dark','violet-hour':'dark','midnight':'dark','forest':'dark','neon-tokyo':'dark','dracula':'dark','monokai':'dark','ocean-deep':'dark','sunset':'dark','arctic':'dark','ember':'dark','vapor':'dark','slate':'dark','matrix':'dark','nord':'dark','gruvbox-dark':'dark','abyss':'dark','copper':'dark','sakura-night':'dark','hacker':'dark','daylight':'light','paper':'light','cloud':'light','meadow':'light','peach':'light','lavender':'light','gruvbox-light':'light','snow':'light','sand':'light','rose':'light','solarized-dark':'dark','solarized-light':'light','catppuccin-mocha':'dark','catppuccin-latte':'light','high-contrast':'dark','halloween':'dark','frost':'dark','dawn':'light','tokyo-day':'light','alucard':'light','monokai-light':'light','shallows':'light','ash':'light','vapor-day':'light','greenbar':'light','nord-light':'light','bright':'light','high-contrast-light':'light','hayride':'light','snowfall':'light' };
// THEMES:END
-- // ── Seasons ──────────────────────────────────────────────
-- // During a season (SEASONS above, the visitor's own calendar) its theme stands in for the default theme for anyone
-- // on the default who has not turned seasonal themes off: the display preference `seasonal` (on|off), saved to the
-- // account and followed on every site like text size. A theme someone picked stays theirs. While one shows, <html>
-- // carries data-ov-season="<id>" and window gets an 'ov:season' event ({ id, name, theme } or null).
-- // The OpenVibe mark (ov-mark.js) wears the season for anyone who has not turned seasonal themes off, on any theme:
-- // <html data-ov-season-mark="<id>">.
++ // ── Seasons: in its dates a season's theme stands in for the default theme unless display `seasonal` is off (a picked
++ // theme stays). <html data-ov-season>, window 'ov:season'; the mark wears it on any theme (data-ov-season-mark).
const DEFAULT_ID = 'vibe';
function seasonNow(date) {
const d = date || new Date();
@@@ -111,11 +98,8 @@
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
-- // theme someone picked, as it is) or 'auto' (its half that matches the device's light or dark setting). The account
-- // menu's Light / Dark switch saves the other half itself (setScheme). A theme without a half (a community or custom
-- // one) gives way to the default theme's half when Auto wants the other mode.
++ // ── Light, dark and Auto: every built-in has two halves (PAIRS); display `scheme` 'auto' paints the half the device
++ // asks for (a theme without one gives way to the default's half), 'manual' the picked theme as it is.
let _mq = null;
function deviceMode() {
try { _mq = _mq || window.matchMedia('(prefers-color-scheme: dark)'); return _mq.matches ? 'dark' : 'light'; } catch (e) { return 'dark'; }
@@@ -134,8 +118,7 @@
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.
++ // A theme being tried (?ov-try=) wins every repaint; _shown keeps the real one for Stop.
if (_trying && _trying.vars) return paintTry();
const half = halfFor(themeId, vars, !!custom);
const s = isDefault(half.id) && !half.custom && readDisplay().seasonal !== 'off' ? seasonNow() : null;
@@@ -158,10 +141,7 @@
/** The Light / Dark / Auto switch's state: 'auto', else the mode of the theme on screen. */
function getScheme() { return readDisplay().scheme === 'auto' ? 'auto' : modeOf(_shown.id, _shown.vars); }
-- /**
-- * Light or Dark: Auto goes off and, when the picked theme is the other mode, its other half is saved as the theme (the
-- * default theme's half for one without); the season stays as it was. Auto: follow the device. → the new state.
-- */
++ /** Light or Dark saves the picked theme's other half (Auto off); Auto follows the device. → the new state. */
function setScheme(mode) {
if (mode === 'auto') { setDisplay({ scheme: 'auto' }); return 'auto'; }
if (mode !== 'light' && mode !== 'dark') return getScheme();
@@@ -181,11 +161,7 @@
const maxAge = days * 24 * 60 * 60;
const encoded = encodeURIComponent(value);
const host = (typeof location !== 'undefined' && location.hostname) || '';
-- // Cookie policy (CONTRACTS.md): a Domain=.openvibe.tools cookie is set ONLY
-- // when actually running on openvibe.tools or one of its subdomains, so the
-- // theme follows the user across the tool satellites. Everywhere else
-- // (openvibe.network, openvibe.live, openvibe.games, localhost, ...) the
-- // cookie is host-only — NO Domain attribute.
++ // Domain=.openvibe.tools only on openvibe.tools and its subdomains (CONTRACTS.md); host-only everywhere else.
if (host && (host.endsWith('.openvibe.tools') || host === 'openvibe.tools')) {
document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;
} else {
@@@ -205,11 +181,7 @@
return true;
}
-- /**
-- * Apply arbitrary CSS variables object to <html>.
-- */
-- // 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>.
++ // Every token any built-in sets: applying a theme clears the ones it does not set (no inherited --success).
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) {
@@@ -235,9 +207,7 @@
// Native controls, scrollbars and form fields follow the theme's light/dark mode.
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.
-- // 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.
++ // The canvas follows the variables (value as fallback), so a page that sets --bg-primary later repaints it too.
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();
@@@ -260,12 +230,8 @@
clearTimeout(_fadeTimer); _fadeTimer = setTimeout(function () { el.classList.remove('ov-theme-fade'); }, 600);
}
-- // ── The browser's own UI (address bar, theme-color) follows the theme ─────────────────────────────────────────────────
-- // The tab icon and the browser/OS toolbar colour are repainted from the theme that was just
-- // applied, so a violet, light or custom theme gets a matching favicon and address bar. Runs on
-- // every apply: first paint, a change on this page, a change in another tab, a server sync.
-- // favicon: only <link rel="icon" data-ov-icon="<site>"> (written by app-icon.headTags) is touched
-- // event: window 'ov:theme' { accent, accentLight, bg, mode } for anything else that wants to follow
++ // ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),
++ // and window 'ov:theme' { accent, accentLight, bg, mode }.
const DOTS = { live: '#ef4444', games: '#facc15' };
const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;
function readColor(name, fallback) {
@@@ -294,12 +260,7 @@
try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }
}
-- /**
-- * Resolve the active theme from (in order):
-- * 1. localStorage 'ov_theme' (has id + variables — instant)
-- * 2. Cookie 'ov_theme_id' (just slug — look up in built-in map)
-- * Falls back to 'vibe'.
-- */
++ /** The theme from localStorage 'ov_theme' (id and variables), else the 'ov_theme_id' cookie, else the default. */
function resolveAndApply() {
// 1. Try localStorage (same-origin cache with full variables)
try {
@@@ -334,25 +295,17 @@
null;
}
-- /**
-- * Write the resolved theme to every local cache. Always stores the FULL variable set —
-- * including for community/custom themes that aren't in the built-in map — so the next
-- * page load can paint the right colours synchronously with no network call and no flash.
-- */
-- function cacheLocally(themeId, vars, custom) {
++ /** Cache the theme's FULL variables (custom ones too) and the date it was chosen, so the next load paints at once. */
++ function cacheLocally(themeId, vars, custom, at) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
-- id: themeId, slug: themeId, variables: vars, at: Date.now(), ...(custom ? { custom: true } : {}),
++ id: themeId, slug: themeId, variables: vars, at: at || Date.now(), ...(custom ? { custom: true } : {}),
}));
} catch { /* quota */ }
setCookie(COOKIE_NAME, themeId, 365);
}
-- /**
-- * Save theme choice locally AND to the user's openvibe.network account, so the choice
-- * follows them to every OpenVibe site. Pass opts.custom for per-user variable overrides;
-- * opts.sync === false keeps it device-local.
-- */
++ /** Save a theme here and to the account (opts.custom: variable overrides; opts.sync === false: this device only). */
function save(themeId, variables, opts) {
const custom = (opts && opts.custom) || null;
const base = variables || THEMES[themeId] || {};
@@@ -373,6 +326,11 @@
}).catch(function () { /* offline / signed out — local cache still holds */ });
}
++ /** When the theme in this browser was chosen (ms), or 0 for a copy older than 4.64.0. */
++ function themeAt() {
++ try { const t = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); return t && Number(t.at) > 0 ? Number(t.at) : 0; } catch { return 0; }
++ }
++
/**
* Get the current theme ID (from cookie or localStorage).
*/
@@@ -387,18 +345,10 @@
return getCookie(COOKIE_NAME) || 'vibe';
}
-- // ── Auto-apply on load (synchronous, prevents FOUC) ─────
-- // ── Display preferences: motion + text size, one setting for every OpenVibe site ─────────────
-- // OpenVibeThemeLoader.display.get() → { motion: 'auto'|'reduced', text: '100'|'112'|'125', seasonal, scheme,
-- // contrast: 'normal'|'more', font: 'default'|'readable', colors: 'default'|'cb-safe', oled: 'off'|'on' }
-- // OpenVibeThemeLoader.display.set({ motion, text }) applies here, in other tabs, and saves to the account
-- // Applied as <html data-ov-motion> and --ov-text-scale before first paint; the shared stylesheet
-- // below makes every shared component honour them, and sites can key their own CSS off the same hooks.
-- // seasonal: 'on'|'off' the season's theme in place of the default theme (see Seasons above)
-- // scheme: 'manual'|'auto' the picked theme as it is, or its half that matches the device (see Light, dark and Auto)
++ // ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)
++ // applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.
const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'] };
-- // Comfort (4.45.0): an attribute on <html> each; !important because the theme is inline (applyVars). contrast=more,
-- // font=readable (Atkinson Hyperlegible/Verdana), colors=cb-safe (Okabe-Ito), oled=on (black on dark themes).
++ // Comfort (4.45.0): one <html> attribute each, !important over the inline theme (Okabe-Ito colours, OLED black).
const COMFORT_CSS = 'html[data-ov-contrast=more]{--text-secondary:color-mix(in srgb,var(--text-primary) 88%,var(--bg-primary))!important;--text-muted:color-mix(in srgb,var(--text-primary) 76%,var(--bg-primary))!important;--border:color-mix(in srgb,var(--text-primary) 40%,var(--bg-primary))!important;--border-light:color-mix(in srgb,var(--text-primary) 55%,var(--bg-primary))!important}'
+ 'html[data-ov-contrast=more] :is(p,dd,td) a:not([class]){text-decoration-line:underline;text-underline-offset:2px}html[data-ov-contrast=more] :focus-visible{outline:3px solid var(--accent-strong,var(--accent))!important;outline-offset:2px}'
+ 'html[data-ov-font=readable]{--ov-font:"Atkinson Hyperlegible",Verdana,Tahoma,"Segoe UI",system-ui,sans-serif!important}html[data-ov-font=readable] body{font-family:var(--ov-font)!important;letter-spacing:.02em;word-spacing:.06em}html[data-ov-font=readable] :is(p,li,dd){line-height:1.7}'
@@@ -419,31 +369,33 @@
for (const k in COMFORT_ATTRS) { const [attr, off] = COMFORT_ATTRS[k]; if (d[k] && d[k] !== off) el.setAttribute(attr, d[k]); else el.removeAttribute(attr); }
if (!document.getElementById('ov-display-css') && document.head) {
const st = document.createElement('style'); st.id = 'ov-display-css';
-- st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-mark .o,html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}' + COMFORT_CSS;
++ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}' + COMFORT_CSS;
document.head.appendChild(st);
}
try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
}
++ /** When the display settings in this browser were last changed (ms), or 0 for a copy older than 4.64.0. */
++ function displayAt() {
++ try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null'); return d && Number(d.at) > 0 ? Number(d.at) : 0; } catch (e) { return 0; }
++ }
function setDisplay(patch, opts) {
const d = readDisplay();
const was = d.seasonal + '|' + d.scheme;
for (const k in DISPLAY_OPTS) if (patch && DISPLAY_OPTS[k].indexOf(String(patch[k])) >= 0) d[k] = String(patch[k]);
-- try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(d)); } catch (e) { /* */ }
++ // A change made here is dated now; one adopted from the account or another site keeps its own date.
++ const at = opts && Number(opts.at) > 0 ? Number(opts.at) : (opts && opts.local ? displayAt() : Date.now());
++ try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: at }))); } catch (e) { /* */ }
applyDisplay(d);
if (d.seasonal + '|' + d.scheme !== was && !(opts && opts.noRepaint)) show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom);
if (!(opts && opts.local) && typeof fetch !== 'undefined') {
const token = authToken(); const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;
-- if (token || /(?:^|; )ov_sso_hint=account/.test(document.cookie)) fetch(_config.apiBase + '/api/themes/me/display', { method: 'PUT', headers: headers, credentials: 'include', keepalive: true, body: JSON.stringify(d) }).catch(function () {});
++ if (token || /(?:^|; )ov_sso_hint=account/.test(document.cookie)) fetch(_config.apiBase + '/api/themes/me/display', { method: 'PUT', headers: headers, credentials: 'include', keepalive: true, body: JSON.stringify(Object.assign({}, d, { at: at })) }).catch(function () {});
}
return d;
}
-- 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.
++ // ── Try a theme (4.41.0): ?ov-try=<catalog name> paints it for this tab without saving (?ov-try=off ends it); a bar
++ // offers Use it (saves to the account) and Stop. Only a name is read from the URL, never colours.
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(' '); };
@@@ -540,7 +492,83 @@
document.body.appendChild(bar);
}
++ // ── The look travels (4.64.0): each domain keeps its own copy, so a click to another OpenVibe site carries ?ov_look=
++ // (what this page paints, the display and theme with their dates). Added at click time, read only after an OpenVibe
++ // page, only colours pass; the next site keeps it when newer and drops it from the address before first paint.
++ const OV_HOST = /(^|\.)(openvibe\.[a-z]{2,12}|openre\.stream)$/i;
++ const PAINT_KEY = 'ov_paint';
++ const PAINT_ATTRS = ['data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text', 'data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled'];
++ const SAFE_VALUE = /^(#[0-9a-f]{3,8}|rgba?\([0-9.,\s%]+\)|[0-9.,\s]+|light|dark|(-?[0-9.]+(px)?\s+){2,4}rgba?\([0-9.,\s%]+\))$/i;
++ const SAFE_ATTR = /^[a-z0-9-]{1,40}$/;
++ function seasonUntil(s) {
++ if (!s) return 0;
++ const d = new Date(), y = d.getFullYear(), to = s.to.split('-');
++ const end = new Date(y + (s.from > s.to && s.from <= String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') ? 1 : 0), Number(to[0]) - 1, Number(to[1]) + 1);
++ return end.getTime();
++ }
++ function paintRecord() {
++ const el = document.documentElement, a = {};
++ for (let i = 0; i < PAINT_ATTRS.length; i++) { const v = el.getAttribute(PAINT_ATTRS[i]); if (v) a[PAINT_ATTRS[i]] = v; }
++ return { k: THEME_KEYS.map(function (k) { return el.style.getPropertyValue(k).trim(); }), a: a, ts: el.style.getPropertyValue('--ov-text-scale').trim(), u: seasonUntil(_season), at: Date.now() };
++ }
++ let _paintTimer = 0;
++ function rememberPaint() {
++ if (_paintTimer || typeof setTimeout === 'undefined') return;
++ _paintTimer = setTimeout(function () { _paintTimer = 0; if (_trying) return; try { localStorage.setItem(PAINT_KEY, JSON.stringify(paintRecord())); } catch (e) { /* */ } }, 0);
++ }
++ function lookToken() {
++ const rec = { v: 1, d: readDisplay(), da: displayAt(), t: _shown.id, c: _shown.custom ? 1 : 0, ta: themeAt(), p: paintRecord() };
++ return btoa(JSON.stringify(rec)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
++ }
++ function readLook() {
++ try {
++ if (typeof location === 'undefined' || !/[?&]ov_look=/.test(location.search)) return null;
++ const u = new URL(location.href), raw = u.searchParams.get('ov_look') || '';
++ u.searchParams.delete('ov_look');
++ try { history.replaceState(history.state, '', u.pathname + (u.search || '') + u.hash); } catch (e) { /* */ }
++ const from = document.referrer ? new URL(document.referrer).hostname : '';
++ if (!OV_HOST.test(from) || raw.length > 4000) return null;
++ const L = JSON.parse(atob(raw.replace(/-/g, '+').replace(/_/g, '/')));
++ return L && L.v === 1 ? L : null;
++ } catch (e) { return null; }
++ }
++ function adoptLook(L) {
++ if (!L) return;
++ const now = Date.now(), clamp = function (n) { n = Number(n) || 0; return n > now + 60000 ? now : n; };
++ const da = clamp(L.da), ta = clamp(L.ta);
++ if (L.d && typeof L.d === 'object' && da > displayAt()) {
++ const d = readDisplay();
++ for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(L.d[k])) >= 0) d[k] = String(L.d[k]);
++ try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(Object.assign({}, d, { at: da }))); } catch (e) { /* */ }
++ }
++ const id = String(L.t || '');
++ if (TRY_ID.test(id) && ta > themeAt()) {
++ if (!L.c && THEMES[id]) cacheLocally(id, THEMES[id], false, ta);
++ else if (L.c && L.p && Array.isArray(L.p.k)) {
++ const vars = {};
++ for (let i = 0; i < THEME_KEYS.length; i++) { const v = String(L.p.k[i] || ''); if (v && SAFE_VALUE.test(v)) vars[THEME_KEYS[i]] = v; }
++ if (Object.keys(vars).length > 8) cacheLocally(id, vars, true, ta);
++ }
++ }
++ }
++ function decorateLink(e) {
++ const a = e.target && e.target.closest && e.target.closest('a[href]');
++ if (!a || a.hasAttribute('download')) return;
++ let u; try { u = new URL(a.href, location.href); } catch (err) { return; }
++ if (!/^https?:$/.test(u.protocol) || u.hostname === location.hostname || !OV_HOST.test(u.hostname)) return;
++ try { u.searchParams.set('ov_look', lookToken()); a.href = u.toString(); } catch (err) { /* */ }
++ }
++ const _arrived = readLook();
++ adoptLook(_arrived);
++ applyDisplay(readDisplay());
++ if (typeof document !== 'undefined' && document.addEventListener) {
++ ['pointerdown', 'keydown', 'click'].forEach(function (ev) { document.addEventListener(ev, function (e) { if (ev !== 'keydown' || e.key === 'Enter') decorateLink(e); }, true); });
++ window.addEventListener('ov:theme', rememberPaint);
++ window.addEventListener('ov:display', rememberPaint);
++ }
++
const activeId = resolveAndApply();
++ rememberPaint();
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(); });
@@@ -555,9 +583,7 @@
} catch (e) { /* no media queries */ }
}
-- // Look up a theme we don't ship in the built-in map (community/custom themes) from the
-- // central catalog. Without this, any non-built-in id fell through to the default 'vibe'
-- // — which is why a custom theme could "disappear" and flash purple on every load.
++ // A theme not in the built-in map (community, custom) comes from the catalog, never falls back to the default.
function fetchThemeVars(themeId) {
return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))
.then(function (r) { return r.ok ? r.json() : null; })
@@@ -565,13 +591,8 @@
.catch(function () { return null; });
}
-- // ── Pull the account's theme from openvibe.network ───────
-- // Runs IMMEDIATELY, not on a 500ms timer: the cached theme is already painted, so this is
-- // a silent correction — delaying it only widened the window where a cold or stale cache
-- // showed the wrong theme. There is also no token gate any more; the ov_token cookie rides
-- // along via credentials:'include' (openvibe.network allows credentialed CORS per origin),
-- // 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".
++ // ── The account's look from openvibe.network, at once (the cached look is already painted; newest copy wins). No
++ // token gate: the ov_token cookie rides along (credentials: 'include'), for sites whose script cannot read it.
let _lastSync = 0;
/** This site's id from <html data-ov-service> (openvibe-shared/shell writes it), or '' (4.43.0). */
function siteService() {
@@@ -582,14 +603,21 @@
_lastSync = Date.now();
const token = authToken();
const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
-- // 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).
++ // A per-site look set in OpenVibe.Design ("Games in Neon Tokyo") answers for ?service=<this site> (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(); const next = {}; for (const k in DISPLAY_OPTS) next[k] = data.display[k] || cur[k]; if (JSON.stringify(next) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true }); } }
++ if (data && data.display) {
++ const cur = readDisplay(); const next = {}; for (const k in DISPLAY_OPTS) next[k] = data.display[k] || cur[k];
++ const serverAt = Number(data.display_at) || 0, localAt = displayAt();
++ const takeServer = serverAt ? serverAt > localAt : !(localAt && Date.now() - localAt < 60000);
++ if (takeServer && JSON.stringify(next) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true, at: serverAt || localAt }); }
++ else if (serverAt && localAt > serverAt && JSON.stringify(next) !== JSON.stringify(cur)) setDisplay(cur, { at: localAt }); // newer here: the account catches up
++ }
if (!data || !data.theme_id) return;
++ const serverThemeAt = Number(data.theme_at) || 0, localThemeAt = themeAt();
++ if (serverThemeAt ? serverThemeAt < localThemeAt : (localThemeAt && Date.now() - localThemeAt < 60000)) return; // a newer pick here
const serverId = data.theme_id;
const custom = (data.custom_variables && typeof data.custom_variables === 'object'
&& Object.keys(data.custom_variables).length) ? data.custom_variables : null;
@@@ -597,7 +625,7 @@
const settle = function (base) {
const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
if (!vars || !Object.keys(vars).length) return;
-- cacheLocally(serverId, vars, !!custom); // cacheLocally, not save() — no write-back loop
++ cacheLocally(serverId, vars, !!custom, serverThemeAt || localThemeAt || 1); // cacheLocally, not save() — no write-back loop
if (_shown.id === serverId && !!_shown.custom === !!custom && JSON.stringify(_shown.vars) === JSON.stringify(vars)) return;
fadeNext();
show(serverId, vars, !!custom);
@@@ -608,10 +636,8 @@
.catch(function () { /* offline / signed out — keep the cached theme */ });
}
syncFromServer();
-- // Every site keeps its own copy, so one visited after a change elsewhere paints its old copy first and the account's
-- // answer corrects it (faded, not a jolt). The account is asked again when the page comes back into view (a change made
-- // in another site's tab), and once more shortly after arriving from another site, since a change saved there just
-- // before following the link may land after this page first asked.
++ // Asked again when the page comes back into view, and shortly after arriving from another site (a change saved there
++ // just before the link may land after this page first asked).
if (typeof document !== 'undefined' && document.addEventListener) {
const again = function () { if (Date.now() - _lastSync > 10000) syncFromServer(); };
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') again(); });