openvibers/openvibe.shared Commits
4.64.0: one look everywhere, no flash; the Look panel in the account menu; Calm keeps the O (#216)
Changes
@@@ -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'@@@ -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 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.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)",@@@ -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, FedCMnnew file mode 100644@@@ -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 };@@@ -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 menuburger.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();nnew file mode 100644@@@ -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);@@@ -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",@@@ -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(', ')} };`;nnew file mode 100644@@@ -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`);nnew file mode 100644@@@ -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); });@@@ -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 & 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 & 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');@@@ -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.@@@ -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 hereconst 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 loopif (_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(); });
@@ -6,6 +6,31 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,31 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 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'
- **37 more glyphs, for the Font Awesome names the network still used most** (plan L32 I4; openvibe.design/icons'
@@ -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.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
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.63.0",
+ "version": "4.64.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)",
@@ -26,6 +26,7 @@ const BROWSER = Object.freeze([
@@ -26,6 +26,7 @@ const BROWSER = Object.freeze([
'user-card.js',
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
'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-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)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'history.js', // cross-site history
'history.js', // cross-site history
'sso-client.js', // link hand-off, FedCM
'sso-client.js', // link hand-off, FedCM
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -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 };
@@ -390,6 +390,7 @@
@@ -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-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 { 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 .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 { 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[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); }
.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 @@
@@ -475,7 +476,7 @@
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// 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
// 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
// (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">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -805,12 +806,7 @@
@@ -805,12 +806,7 @@
const first = pop.querySelector('button'); if (first) first.focus();
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;
let _iconsQueued = false;
function loadOvIcons(scope) {
function loadOvIcons(scope) {
if (root.OpenVibeIcons) { if (scope && root.OpenVibeIcons.mount) root.OpenVibeIcons.mount(scope); return; }
if (root.OpenVibeIcons) { if (scope && root.OpenVibeIcons.mount) root.OpenVibeIcons.mount(scope); return; }
@@ -870,7 +866,7 @@
@@ -870,7 +866,7 @@
const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd'));
const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd'));
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
if (burger && drawer) {
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); });
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) => {
drawer.addEventListener('click', (e) => {
const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return;
const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return;
@@ -907,6 +903,20 @@
@@ -907,6 +903,20 @@
box.hidden = false; paint();
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. */
/** Display rows (text size, animations) cycle their value; state lives in theme-loader.js. */
function bindDisplayRows(dropdown) {
function bindDisplayRows(dropdown) {
bindScheme(dropdown);
bindScheme(dropdown);
@@ -968,7 +978,7 @@
@@ -968,7 +978,7 @@
const sch = L.scheme ? L.scheme.get() : null;
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>`;
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>
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.hidden = false;
};
};
box.addEventListener('click', (e) => {
box.addEventListener('click', (e) => {
@@ -1238,11 +1248,7 @@
@@ -1238,11 +1248,7 @@
function resolveLoginHref(host, returnUrl) {
function resolveLoginHref(host, returnUrl) {
if (_config.loginUrl) return fillUrl(_config.loginUrl, returnUrl);
if (_config.loginUrl) return fillUrl(_config.loginUrl, returnUrl);
if (onToolsDomain(host)) {
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)}`;
return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;
}
}
if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
@@ -1381,12 +1387,7 @@
@@ -1381,12 +1387,7 @@
try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }
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;
let _ssoClientLoading = null;
function loadSsoClient() {
function loadSsoClient() {
if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
@@ -1747,8 +1748,9 @@
@@ -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>
<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 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>
<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>
+ <div class="ud-look" hidden></div>
</div>
</div>
`;
`;
dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });
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 @@
@@ -1758,6 +1760,7 @@
bindChips(dropdown);
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'); } });
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);
bindDisplayRows(dropdown);
+ bindLook(dropdown);
regPanel(dropdown, 'user-menu');
regPanel(dropdown, 'user-menu');
// Avatar click toggles dropdown (the selection for Ask Vibe is read on pointerdown: the press can clear it)
// Avatar click toggles dropdown (the selection for Ask Vibe is read on pointerdown: the press can clear it)
@@ -2004,11 +2007,7 @@
@@ -2004,11 +2007,7 @@
const el = render();
const el = render();
upgradeIconsWhenFontsReady();
upgradeIconsWhenFontsReady();
if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000);
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().
// auth: 'external' — the site signs people in itself (Live) and tells us with setUser().
if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
else if (!_config.user) refreshAuthState();
else if (!_config.user) refreshAuthState();
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -51,6 +51,8 @@
@@ -51,6 +51,8 @@
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
"./ov-icons": "./ov-icons.js",
"./ov-icons": "./ov-icons.js",
"./ov-glyphs": "./ov-glyphs.js",
"./ov-glyphs": "./ov-glyphs.js",
+ "./ov-look": "./ov-look.js",
+ "./look-boot": "./look-boot.js",
"./ov-mark": "./ov-mark.js",
"./ov-mark": "./ov-mark.js",
"./package.json": "./package.json",
"./package.json": "./package.json",
"./panels": "./panels.js",
"./panels": "./panels.js",
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { hashAsset } = require('../assets');
const { hashAsset } = require('../assets');
const ROOT = path.join(__dirname, '..');
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 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(', ')} };`;
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -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`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -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); });
@@ -237,7 +237,7 @@ function load(hostname, opts = {}) {
@@ -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
// 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.
// 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(/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 & 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 & 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(/<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('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');
assert.ok(!dd.innerHTML.includes('Across OpenVibe'), 'the other sites are the launcher\'s and the drawer\'s, not the account menu\'s');
@@ -48,7 +48,8 @@ for (const t of BUILTIN_THEMES) {
@@ -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`);
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(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');
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.
// 5. Derived tokens: text on the accent always reads, on every theme.
@@ -1,19 +1,11 @@
@@ -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) {
(function (root) {
'use strict';
'use strict';
const COOKIE_NAME = 'ov_theme_id';
const COOKIE_NAME = 'ov_theme_id';
const STORAGE_KEY = 'ov_theme';
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';
const DEFAULT_API_BASE = 'https://openvibe.network';
let _config = { apiBase: DEFAULT_API_BASE };
let _config = { apiBase: DEFAULT_API_BASE };
@@ -90,13 +82,8 @@
@@ -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' };
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
// 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';
const DEFAULT_ID = 'vibe';
function seasonNow(date) {
function seasonNow(date) {
const d = date || new Date();
const d = date || new Date();
@@ -111,11 +98,8 @@
@@ -111,11 +98,8 @@
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)
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 ─────────────────────────────────
- // 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;
let _mq = null;
function deviceMode() {
function deviceMode() {
try { _mq = _mq || window.matchMedia('(prefers-color-scheme: dark)'); return _mq.matches ? 'dark' : 'light'; } catch (e) { return 'dark'; }
try { _mq = _mq || window.matchMedia('(prefers-color-scheme: dark)'); return _mq.matches ? 'dark' : 'light'; } catch (e) { return 'dark'; }
@@ -134,8 +118,7 @@
@@ -134,8 +118,7 @@
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.
+ // A theme being tried (?ov-try=) wins every repaint; _shown keeps the real one for Stop.
if (_trying && _trying.vars) return paintTry();
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;
@@ -158,10 +141,7 @@
@@ -158,10 +141,7 @@
/** The Light / Dark / Auto switch's state: 'auto', else the mode of the theme on screen. */
/** 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); }
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) {
function setScheme(mode) {
if (mode === 'auto') { setDisplay({ scheme: 'auto' }); return 'auto'; }
if (mode === 'auto') { setDisplay({ scheme: 'auto' }); return 'auto'; }
if (mode !== 'light' && mode !== 'dark') return getScheme();
if (mode !== 'light' && mode !== 'dark') return getScheme();
@@ -181,11 +161,7 @@
@@ -181,11 +161,7 @@
const maxAge = days * 24 * 60 * 60;
const maxAge = days * 24 * 60 * 60;
const encoded = encodeURIComponent(value);
const encoded = encodeURIComponent(value);
const host = (typeof location !== 'undefined' && location.hostname) || '';
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')) {
if (host && (host.endsWith('.openvibe.tools') || host === 'openvibe.tools')) {
document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;
document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;
} else {
} else {
@@ -205,11 +181,7 @@
@@ -205,11 +181,7 @@
return true;
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); })();
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). */
/** Readable text on a colour: near-black or white, whichever contrasts more (WCAG relative luminance). */
function onColor(c) {
function onColor(c) {
@@ -235,9 +207,7 @@
@@ -235,9 +207,7 @@
// Native controls, scrollbars and form fields follow the theme's light/dark mode.
// Native controls, scrollbars and form fields follow the theme's light/dark mode.
const scheme = vars['--color-scheme'];
const scheme = vars['--color-scheme'];
if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', 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['--bg-primary']) el.style.background = `var(--bg-primary, ${vars['--bg-primary']})`;
if (vars['--text-primary']) el.style.color = `var(--text-primary, ${vars['--text-primary']})`;
if (vars['--text-primary']) el.style.color = `var(--text-primary, ${vars['--text-primary']})`;
syncBrowserUi();
syncBrowserUi();
@@ -260,12 +230,8 @@
@@ -260,12 +230,8 @@
clearTimeout(_fadeTimer); _fadeTimer = setTimeout(function () { el.classList.remove('ov-theme-fade'); }, 600);
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 DOTS = { live: '#ef4444', games: '#facc15' };
const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;
const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;
function readColor(name, fallback) {
function readColor(name, fallback) {
@@ -294,12 +260,7 @@
@@ -294,12 +260,7 @@
try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }
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() {
function resolveAndApply() {
// 1. Try localStorage (same-origin cache with full variables)
// 1. Try localStorage (same-origin cache with full variables)
try {
try {
@@ -334,25 +295,17 @@
@@ -334,25 +295,17 @@
null;
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 {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
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 */ }
} catch { /* quota */ }
setCookie(COOKIE_NAME, themeId, 365);
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) {
function save(themeId, variables, opts) {
const custom = (opts && opts.custom) || null;
const custom = (opts && opts.custom) || null;
const base = variables || THEMES[themeId] || {};
const base = variables || THEMES[themeId] || {};
@@ -373,6 +326,11 @@
@@ -373,6 +326,11 @@
}).catch(function () { /* offline / signed out — local cache still holds */ });
}).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).
* Get the current theme ID (from cookie or localStorage).
*/
*/
@@ -387,18 +345,10 @@
@@ -387,18 +345,10 @@
return getCookie(COOKIE_NAME) || 'vibe';
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'] };
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}'
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-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}'
+ '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 @@
@@ -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); }
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) {
if (!document.getElementById('ov-display-css') && document.head) {
const st = document.createElement('style'); st.id = 'ov-display-css';
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);
document.head.appendChild(st);
}
}
try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
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) {
function setDisplay(patch, opts) {
const d = readDisplay();
const d = readDisplay();
const was = d.seasonal + '|' + d.scheme;
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]);
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);
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 (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') {
if (!(opts && opts.local) && typeof fetch !== 'undefined') {
const token = authToken(); const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;
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;
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_KEY = 'ov_try';
const TRY_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
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(' '); };
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 @@
@@ -540,7 +492,83 @@
document.body.appendChild(bar);
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();
const activeId = resolveAndApply();
+ rememberPaint();
startTry(readTry());
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(); });
@@ -555,9 +583,7 @@
@@ -555,9 +583,7 @@
} catch (e) { /* no media queries */ }
} 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) {
function fetchThemeVars(themeId) {
return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))
return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (r) { return r.ok ? r.json() : null; })
@@ -565,13 +591,8 @@
@@ -565,13 +591,8 @@
.catch(function () { return null; });
.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;
let _lastSync = 0;
/** This site's id from <html data-ov-service> (openvibe-shared/shell writes it), or '' (4.43.0). */
/** This site's id from <html data-ov-service> (openvibe-shared/shell writes it), or '' (4.43.0). */
function siteService() {
function siteService() {
@@ -582,14 +603,21 @@
@@ -582,14 +603,21 @@
_lastSync = Date.now();
_lastSync = Date.now();
const token = authToken();
const token = authToken();
const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
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();
const site = siteService();
fetch(_config.apiBase + '/api/themes/me/active' + (site ? '?service=' + encodeURIComponent(site) : ''), { headers: headers, credentials: 'include' })
fetch(_config.apiBase + '/api/themes/me/active' + (site ? '?service=' + encodeURIComponent(site) : ''), { headers: headers, credentials: 'include' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
.then(function (data) {
- if (data && data.display) { const cur = readDisplay(); 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;
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 serverId = data.theme_id;
const custom = (data.custom_variables && typeof data.custom_variables === 'object'
const custom = (data.custom_variables && typeof data.custom_variables === 'object'
&& Object.keys(data.custom_variables).length) ? data.custom_variables : null;
&& Object.keys(data.custom_variables).length) ? data.custom_variables : null;
@@ -597,7 +625,7 @@
@@ -597,7 +625,7 @@
const settle = function (base) {
const settle = function (base) {
const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
if (!vars || !Object.keys(vars).length) return;
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;
if (_shown.id === serverId && !!_shown.custom === !!custom && JSON.stringify(_shown.vars) === JSON.stringify(vars)) return;
fadeNext();
fadeNext();
show(serverId, vars, !!custom);
show(serverId, vars, !!custom);
@@ -608,10 +636,8 @@
@@ -608,10 +636,8 @@
.catch(function () { /* offline / signed out — keep the cached theme */ });
.catch(function () { /* offline / signed out — keep the cached theme */ });
}
}
syncFromServer();
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) {
if (typeof document !== 'undefined' && document.addEventListener) {
const again = function () { if (Date.now() - _lastSync > 10000) syncFromServer(); };
const again = function () { if (Date.now() - _lastSync > 10000) syncFromServer(); };
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') again(); });
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') again(); });