openvibers/openvibe.shared Commits
The OpenVibe mark wears the season (no pumpkin button), theme changes follow you between sites without a jolt, and your name in the account menu opens your profile (4.12.0) (#104)
Changes
@@@ -4,6 +4,32 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.12.0 — 2026-10-26++++- **The OpenVibe mark wears the season; the pumpkin button is gone.** The owner, 2026-10-25: the badge beside the brand++ "takes up a lot of space … especially on mobile"; integrate it into the logo instead.++ - **The mark's look** (`ov-mark.js`): `<html data-ov-season-mark="halloween|winter">` (set by `theme-loader.js` for++ anyone who has not turned seasonal themes off, whatever their theme) dresses every mark.++ - October: a pumpkin's stem, leaf and faint ribs, with the V as its grin.++ - December: a cap of snow on the ring.++ - **Moments:**++ - The leaf unfurls when the mark plays its intro.++ - Every 4 to 8 minutes a mark on screen flickers like a candle for a second.++ - On the first visit of the day, a few leaves (snowflakes in December) drift down from the first mark seen.++ - None of this runs under either reduced-motion setting.++ - **Navbar:** no `.ovnav-season` badge. The switch stays in the account menu ("Seasonal themes") and the display box.++- **Theme changes follow you between sites without a jolt** (`theme-loader.js`). The owner, 2026-10-25: a seasonal++ toggle on Live, then Themes in the menu, showed openvibe.network in the old theme until a refresh.++ - **Saves outlive the page:** both PUTs (`/api/themes/me`, `/api/themes/me/display`) are `keepalive`, so a choice made++ just before following a link still lands.++ - **The account is asked again:**++ - when the page comes back into view (at most every 10 s);++ - once more 2.5 s after arriving from another site, since that site's save may land after this page first asked.++ - **Corrections fade:** a theme that changes under someone (the account's answer, another tab) crossfades for 0.4 s++ instead of jumping. An answer that changes nothing repaints nothing. Calm motion never fades.++- **Your name in the account menu opens your profile.** The avatar and name at the top of the menu link to++ `openvibe.network/@username` (an anonymous visitor's to `/my`). They were plain text, and pressing them did nothing.++## 4.11.0 — 2026-10-25- **Seasonal themes.** The owner, 2026-10-24: a Halloween theme and style on every site, as the default during@@@ -61,10 +61,6 @@.ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 2px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }.ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }.ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }-- /* The season's badge (theme-loader.js Seasons): pressed while its theme shows, faded when seasonal themes are off. */-- .ovnav-season { all: unset; box-sizing: border-box; cursor: pointer; width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 2px; }-- .ovnav-season:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }-- .ovnav-season[aria-pressed="false"] { opacity: .5; filter: grayscale(1); }.ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }.ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }@@@ -85,6 +81,9 @@.ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }.openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }.openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }++ .openvibe-navbar-dropdown-header a.ud-me { color: inherit; text-decoration: none; display: block; line-height: 0; }++ .openvibe-navbar-dropdown-header a.name.ud-me { line-height: inherit; display: block; }++ .openvibe-navbar-dropdown-header a.name.ud-me:hover, .openvibe-navbar-dropdown-header a.name.ud-me:focus-visible { text-decoration: underline; }.openvibe-navbar-dropdown .ovnav-chip--menu { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font: 700 12px/1.2 inherit; color: var(--chip-c, #fbbf24); background: color-mix(in srgb, var(--chip-c, #fbbf24) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, #fbbf24) 34%, transparent); }.openvibe-navbar .ovnav-burger { all: unset; box-sizing: border-box; cursor: pointer; display: none; width: 38px; height: 38px; border-radius: 11px; flex-direction: column; align-items: center; justify-content: center; gap: 4px; flex: none; }.openvibe-navbar .ovnav-burger span { display: block; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transition: transform .2s, opacity .2s; }@@@ -531,28 +530,12 @@return { sub: brand.href, core: brand.joined ? apex : 'https://openvibe.network/', tld: apex, apex };}-- // ── The season's badge beside the brand ─────────────────────────────────────────-- // While a season runs (theme-loader.js SEASONS) the bar shows its emblem; a click flips the `seasonal` display-- // preference (on a theme of their own, it opens the themes page).-- const SEASON_ICON = {-- halloween: '<path d="M12 7c-1.3-1.5-4.7-1.7-6.5.3-1.9 2.1-2 6.8-.2 9.6 1.6 2.4 4.6 2.8 6.7 1.5 2.1 1.3 5.1.9 6.7-1.5 1.8-2.8 1.7-7.5-.2-9.6C17 5.3 13.4 5.5 12 7Z" fill="#ff7a1a"/><path d="M12 7c0-1.7.5-3 2-3.6" stroke="#62b33a" stroke-width="2" fill="none"/>',-- winter: '<path d="M12 3v18M4.2 7.5l15.6 9M4.2 16.5l15.6-9" stroke="#7ee8da" stroke-width="2" stroke-linecap="round"/>',-- };++ // ── The season (theme-loader.js Seasons) ────────────────────────────────────────++ // The OpenVibe mark wears it (ov-mark.js); the menus carry the switch (the account menu's "Seasonal themes" row and++ // the display box). → { s, on, off } during a season, else null.function seasonState() {const L = root.OpenVibeThemeLoader, s = L && L.seasons && L.seasons.now();-- return s && SEASON_ICON[s.id] ? { s, on: !!L.seasons.showing(), off: L.display.get().seasonal === 'off' } : null;-- }-- const seasonTitle = (st) => (st.on ? `${st.s.name} theme on: select to turn seasonal themes off` : `Seasonal themes${st.off ? ' off: select to turn them on' : ': pick the default theme to see them'}`);-- function seasonBadgeHTML() {-- const st = seasonState();-- return st ? `<button type="button" class="ovnav-season" aria-pressed="${st.on}" title="${seasonTitle(st)}" aria-label="${seasonTitle(st)}"><svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">${SEASON_ICON[st.s.id]}</svg></button>` : '';-- }-- function bindSeasonBadge(nav) {-- if (!seasonState()) return;-- const btn = nav.querySelector('.ovnav-season'), L = root.OpenVibeThemeLoader;-- const paint = () => { const st = seasonState(); if (st) { btn.setAttribute('aria-pressed', String(st.on)); btn.title = seasonTitle(st); btn.setAttribute('aria-label', btn.title); } };-- btn.addEventListener('click', () => { const st = seasonState(); if (st.on || st.off) L.display.set({ seasonal: st.on ? 'off' : 'on' }); else window.location.href = 'https://openvibe.network/themes'; paint(); });-- if (root.addEventListener) root.addEventListener('ov:season', paint, onDocument());++ return s ? { s, on: !!L.seasons.showing(), off: L.display.get().seasonal === 'off' } : null;}function brandHTML(brand) {@@@ -570,7 +553,6 @@<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a><span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span></div>-- ${seasonBadgeHTML()}${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;}@@@ -1433,7 +1415,6 @@bindLinks(nav, links);bindChips(nav);bindLauncher(nav);-- bindSeasonBadge(nav);countView();// Pages that wire the bell themselves run right after init(); give them the first go.setTimeout(() => mountBell(nav, u), 0);@@@ -1445,15 +1426,17 @@dropdown.id = 'openvibe-user-dropdown';const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));++ // The avatar and name lead to the person's profile (an anonymous visitor's account page): people press them.++ const meHref = !isAnon && u.username ? `https://openvibe.network/@${encodeURIComponent(u.username)}` : 'https://openvibe.network/my';const menuCfg = _config.menu || {};const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);dropdown.innerHTML = `<div class="openvibe-navbar-dropdown-header">-- ${avatarImg(u, 72, '', '')}++ <a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a><div class="info">-- <div class="name">${escapeAttr(u.display_name || u.username)}</div>++ <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeAttr(u.display_name || u.username)}</a><div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}@@@ -16,6 +16,11 @@* so it keeps its look. It never moves while the page is hidden, while it is off-screen* (IntersectionObserver), with data-static, under prefers-reduced-motion or under the network's* own reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js).++ *++ * The season (theme-loader.js sets <html data-ov-season-mark="halloween|winter"> for anyone who has not turned seasonal++ * themes off, on any theme): the mark wears it, a pumpkin's stem, leaf and ribs in October, a cap of snow in December.++ * Now and then (every 4 to 8 minutes) a mark on screen flickers for a moment, and on the first visit of the day a few++ * leaves (snowflakes in December) drift down from the first mark seen. Never under either reduced-motion setting.*/(function () {'use strict';@@@ -73,15 +78,37 @@@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}--@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d{animation:none!important}.ov-mark .o{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;++.ov-mark .sh,.ov-mark .sw{display:none}++html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw{display:inline}++.ov-mark .sr{fill:none;stroke:currentColor;stroke-width:2;opacity:.28}++.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:3.2;stroke-linecap:round}++.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 70%}++.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:5.4;stroke-linecap:round}++.ov-mark .sd{fill:#f4fbff}++html[data-ov-season-mark=halloween] .ov-mark .o{fill:#ffc27a}++html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) both}++.ov-mark[data-ovm-flourish] .sr{animation:ovmFlicker 1.6s linear 1}++.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}++.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}++.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:2147483000}++.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}++html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}++@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}++@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}++@keyframes ovmFlicker{0%,100%{opacity:.28}12%{opacity:.7}22%{opacity:.2}38%{opacity:.8}55%{opacity:.35}72%{opacity:.65}}++@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}++@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}80%{opacity:.9}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}++@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d,.ov-mark .sl,.ov-mark .sr,.ov-mark .sd{animation:none!important}.ov-mark .o,.ovm-fall{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;function svg(id) {return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs><linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient><linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient><path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs><circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>--<circle class="p" cx="24" cy="24" r="18"/>++<circle class="p" cx="24" cy="24" r="18"/><g class="sh"><path class="sr" d="M24 6.5C16.5 12 16.5 36 24 41.5M24 6.5C31.5 12 31.5 36 24 41.5"/></g><path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>++<g class="sh"><path class="ss" d="M23.4 7.4c.2-2.6 1-4.4 2.6-5.6"/><path class="sl" d="M25.8 3.6c2.2-2.4 5.6-2.6 8-1.2-1.8 2.6-5.2 3.4-8 1.2z"/></g>++<g class="sw"><path class="sc" d="M12.4 10.2A18 18 0 0 1 35.6 10.2"/><circle class="sd" cx="16.6" cy="9.8" r="1.6"/><circle class="sd" cx="30.6" cy="9.4" r="1.3"/></g><circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle><circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;}@@@ -106,7 +133,7 @@if (!el.isConnected || !svgEl) { forget(el); return; }const now = Date.now();const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;-- if (may && !st.introEnd) st.introEnd = introDeadline(now);++ if (may && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }const s = el.getAttribute('data-state');const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }@@@ -132,6 +159,39 @@try { el.querySelector('svg').pauseAnimations(); } catch { /* */ }}function updateAll() { document.querySelectorAll('.ov-mark[data-ov]').forEach(update); }++ // ── The season ──++ const season = () => document.documentElement.getAttribute('data-ov-season-mark');++ let flourishTimer = 0;++ function scheduleFlourish() {++ clearTimeout(flourishTimer); flourishTimer = 0;++ if (!season()) return;++ flourishTimer = setTimeout(() => {++ if (season() && !reduced() && !document.hidden) {++ document.querySelectorAll('.ov-mark[data-ov]:not([data-static])').forEach((el) => {++ const st = state.get(el); if (!st || st.onScreen === false) return;++ el.setAttribute('data-ovm-flourish', ''); setTimeout(() => el.removeAttribute('data-ovm-flourish'), 1700);++ });++ }++ scheduleFlourish();++ }, (4 + Math.random() * 4) * 60000);++ }++ const FALL_KEY = 'ov-season-fall';++ function fallOnce(el) {++ const id = season(); if (!id || reduced() || document.hidden || !el.isConnected) return;++ const day = id + ':' + new Date().toDateString();++ try { if (localStorage.getItem(FALL_KEY) === day) return; localStorage.setItem(FALL_KEY, day); } catch { return; }++ const r = el.getBoundingClientRect(); if (!r.width || !document.body) return;++ const box = document.createElement('div'); box.className = 'ovm-fall'; box.setAttribute('aria-hidden', 'true');++ box.style.left = (r.left + r.width / 2) + 'px'; box.style.top = (r.top + r.height * 0.6) + 'px';++ const colors = id === 'winter' ? ['#f4fbff', '#d8f3ff', '#ffffff'] : ['#ff7a1a', '#f5a524', '#d9481f', '#7fd04e'];++ for (let i = 0; i < 7; i++) {++ const s = document.createElement('i');++ s.style.cssText = `--x:${Math.round((Math.random() - 0.3) * 140)}px;--y:${Math.round(120 + Math.random() * 160)}px;--r:${Math.round(Math.random() * 540 - 270)}deg;animation-delay:${(i * 0.18 + Math.random() * 0.2).toFixed(2)}s;animation-duration:${(2.4 + Math.random() * 1.2).toFixed(2)}s;background:${colors[i % colors.length]}`;++ box.appendChild(s);++ }++ document.body.appendChild(box);++ setTimeout(() => box.remove(), 5200);++ }const bound = new WeakSet();function track(el) {state.set(el, { introEnd: 0, lingerEnd: 0, hover: false, focus: false, onScreen: io ? null : true, timer: 0 });@@@ -171,11 +231,12 @@try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }document.addEventListener('visibilitychange', updateAll);mount();++ scheduleFlourish();try {new MutationObserver((records) => {let added = false;for (const r of records) {-- if (r.type === 'attributes') { if (r.target === document.documentElement) updateAll(); else if (state.has(r.target)) update(r.target); continue; }++ if (r.type === 'attributes') { if (r.target === document.documentElement) { updateAll(); if (r.attributeName === 'data-ov-season-mark') scheduleFlourish(); } else if (state.has(r.target)) update(r.target); continue; }if (r.addedNodes.length) added = true;r.removedNodes.forEach((node) => {if (node.nodeType !== 1) return;@@@ -184,7 +245,7 @@});}if (added) mount();-- }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion'] });++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion', 'data-ov-season-mark'] });} catch { /* */ }}window.ovMarkMount = mount;@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.11.0",++ "version": "4.12.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.11.0",++ "version": "4.12.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.11.0",++ "version": "4.12.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -204,31 +204,23 @@ function load(hostname, opts = {}) {assert.ok(dd && dd.innerHTML.includes('data-menu-id="mp"') && dd.innerHTML.includes('My pastes'), 'custom dropdown row rendered');assert.ok(dd.innerHTML.includes('/my#history'), 'History link present');assert.ok(dd.innerHTML.includes('href="https://openvibe.network/@x"') && dd.innerHTML.includes('My Profile'), 'the public profile (openvibe.network/@username) is one tap from the account menu');++ assert.ok(dd.innerHTML.includes('<a class="name ud-me" href="https://openvibe.network/@x"'), 'pressing your name in the menu opens your profile');++ assert.ok(dd.innerHTML.includes('<a class="ud-me" href="https://openvibe.network/@x" tabindex="-1" aria-hidden="true"><img'), 'and so does your picture (one stop for keyboards)');const id = navbar.addMenuItem({ label: 'Runtime row', href: '/r', position: 'before' });assert.ok(id, 'addMenuItem returns an id');}--// ── the season's badge (theme-loader.js Seasons): beside the brand during a season, pressed when its theme shows,--// faded once seasonal themes are off; no season, no badge ────++// ── the season lives in the mark (ov-mark.js), not in a badge beside the brand; the switch stays in the account menu ────{const HALLOWEEN = { id: 'halloween', name: 'Halloween', theme: 'halloween' };-- const loader = (showing, seasonal) => ({-- seasons: { now: () => HALLOWEEN, showing: () => (showing ? HALLOWEEN : null) },-- display: { get: () => ({ text: '100', motion: 'auto', seasonal }), set() {}, options: { text: ['100'], motion: ['auto'], seasonal: ['on', 'off'] } },-- });-- let { navbar, created } = load('openvibe.live');-- global.OpenVibeThemeLoader = loader(true, 'on');-- navbar.init({ service: 'live' });-- let nav = created.find((e) => e.tag === 'nav');-- assert.ok(/<button type="button" class="ovnav-season" aria-pressed="true" title="Halloween theme on: select to turn seasonal themes off"/.test(nav.innerHTML), 'the pumpkin, pressed');-- ({ navbar, created } = load('openvibe.live'));-- global.OpenVibeThemeLoader = loader(false, 'off');-- navbar.init({ service: 'live' });-- nav = created.find((e) => e.tag === 'nav');-- assert.ok(/class="ovnav-season" aria-pressed="false" title="Seasonal themes off: select to turn them on"/.test(nav.innerHTML), 'faded once turned off');-- ({ navbar, created } = load('openvibe.live'));-- global.OpenVibeThemeLoader = { ...loader(false, 'on'), seasons: { now: () => null, showing: () => null } };++ global.OpenVibeThemeLoader = {++ seasons: { now: () => HALLOWEEN, showing: () => HALLOWEEN },++ display: { get: () => ({ text: '100', motion: 'auto', seasonal: 'on' }), set() {}, options: { text: ['100'], motion: ['auto'], seasonal: ['on', 'off'] } },++ };++ const { navbar, created } = load('openvibe.live');navbar.init({ service: 'live' });-- assert.ok(!created.find((e) => e.tag === 'nav').innerHTML.includes('ovnav-season'), 'no season, no badge');++ const nav = created.find((e) => e.tag === 'nav');++ assert.ok(!nav.innerHTML.includes('ovnav-season'), 'no pumpkin button beside the brand');++ assert.ok(nav.innerHTML.includes('class="ov-mark"'), 'the mark, which wears the season itself');delete global.OpenVibeThemeLoader;}console.log('navbar brand: all checks passed');@@@ -13,11 +13,11 @@ const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8const by = Object.fromEntries(BUILTIN_THEMES.map((t) => [t.id, t]));/** A page load on `day` (YYYY-MM-DD, local) with `store` as localStorage and `cookie`. → { L, html, events, puts } */--function load(day, store = new Map(), cookie = '') {++function load(day, store = new Map(), cookie = '', opts = {}) {const [y, m, d] = day.split('-').map(Number);const fixed = new Date(y, m - 1, d, 12).getTime();class FixedDate extends Date { constructor(...a) { if (a.length) super(...a); else super(fixed); } static now() { return fixed; } }-- const attrs = new Map(); const props = new Map(); const events = []; const puts = [];++ const attrs = new Map(); const props = new Map(); const events = []; const puts = []; const gets = []; const timers = []; const docListeners = {};const html = {style: { setProperty: (k, v) => props.set(k, v), removeProperty: (k) => props.delete(k) },setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k),@@@ -26,11 +26,17 @@ function load(day, store = new Map(), cookie = '') {const listeners = {};const sandbox = {Date: FixedDate, JSON, Object, String, Number, Math, Array, Promise, Error,-- document: { documentElement: html, readyState: 'complete', cookie, head: null, querySelector: () => null, getElementById: () => null, addEventListener() {} },++ document: { documentElement: html, readyState: 'complete', cookie, head: null, querySelector: () => null, getElementById: () => null, referrer: opts.referrer || '', visibilityState: 'visible',++ addEventListener: (type, fn) => { (docListeners[type] = docListeners[type] || []).push(fn); } },++ URL, setTimeout: (fn, ms) => timers.push({ fn, ms }), 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: 'openvibe.live', protocol: 'https:' },getComputedStyle: () => ({ getPropertyValue: () => '' }),-- fetch: (url, o) => { if (o && o.method === 'PUT') puts.push({ url, body: JSON.parse(o.body) }); return Promise.resolve({ ok: false, json: () => Promise.resolve(null) }); },++ fetch: (url, o) => {++ if (o && o.method === 'PUT') { puts.push({ url, body: JSON.parse(o.body), keepalive: o.keepalive }); return Promise.resolve({ ok: false, json: () => Promise.resolve(null) }); }++ gets.push(url);++ return Promise.resolve(opts.active ? { ok: true, json: () => Promise.resolve(opts.active) } : { ok: false, json: () => Promise.resolve(null) });++ },CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },dispatchEvent: (e) => { events.push(e); },@@@ -38,7 +44,7 @@ function load(day, store = new Map(), cookie = '') {sandbox.window = sandbox;sandbox.globalThis = sandbox;vm.runInNewContext(SRC, sandbox);-- return { L: sandbox.OpenVibeThemeLoader, html, events, puts, store, fire: (type, e) => (listeners[type] || []).forEach((fn) => fn(e)) };++ return { L: sandbox.OpenVibeThemeLoader, html, events, puts, gets, timers, store, fire: (type, e) => (listeners[type] || []).forEach((fn) => fn(e)), fireDoc: (type) => (docListeners[type] || []).forEach((fn) => fn()) };}const accent = (p) => p.html.props.get('--accent');@@@ -104,4 +110,36 @@ p = load('2026-12-10');assert.strictEqual(accent(p), by.frost.variables['--accent']);assert.strictEqual(p.html.getAttribute('data-ov-season'), 'winter');--console.log('seasonal-themes: all checks passed');++// 7. The mark wears the season for anyone who has not turned seasonal themes off, whatever their theme.++assert.strictEqual(load('2026-10-24').html.getAttribute('data-ov-season-mark'), 'halloween');++p = load('2026-10-24', new Map([['ov_theme', JSON.stringify({ id: 'nord', variables: by.nord.variables })]]));++assert.deepStrictEqual([p.html.getAttribute('data-ov-season'), p.html.getAttribute('data-ov-season-mark')], [null, 'halloween'], 'a picked theme keeps its colours; the mark still wears October');++p = load('2026-10-24');++p.L.display.set({ seasonal: 'off' });++assert.strictEqual(p.html.getAttribute('data-ov-season-mark'), null, 'seasonal off: no seasonal mark either');++assert.strictEqual(load('2026-11-05').html.getAttribute('data-ov-season-mark'), null);++assert.strictEqual(load('2026-12-10').html.getAttribute('data-ov-season-mark'), 'winter');++++// 8. Saves outlive the page (a choice made just before following a link to another site still lands).++p = load('2026-10-24', new Map(), 'ov_sso_hint=account');++p.L.display.set({ seasonal: 'off' });++p.L.save('nord');++assert.ok(p.puts.length === 2 && p.puts.every((x) => x.keepalive === true), 'both saves are keepalive');++++// 9. The account is asked again when the page comes back into view (not more than every 10 s), and once more 2.5 s after++// arriving from another site; its answer corrects the theme.++p = load('2026-10-24');++assert.strictEqual(p.gets.filter((u) => /\/api\/themes\/me\/active$/.test(u)).length, 1, 'asked on load');++p.fireDoc('visibilitychange');++assert.strictEqual(p.gets.length, 1, 'not again within 10 s');++assert.ok(!p.timers.some((t) => t.ms === 2500), 'no extra check without a referrer from another site');++p = load('2026-10-24', new Map(), '', { referrer: 'https://openvibe.network/themes', active: { theme_id: 'nord' } });++const again = p.timers.find((t) => t.ms === 2500);++assert.ok(again, 'arriving from openvibe.network: one more check soon');++again.fn();++assert.strictEqual(p.gets.length, 2);++assert.ok(!load('2026-10-24', new Map(), '', { referrer: 'https://openvibe.live/other' }).timers.some((t) => t.ms === 2500), 'from this same site: no extra check');++setImmediate(() => {++ assert.strictEqual(accent(p), by.nord.variables['--accent'], "the account's theme replaces the stale copy");++ console.log('seasonal-themes: all checks passed');++});@@@ -70,6 +70,8 @@// 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>">.const DEFAULT_ID = 'vibe';function seasonNow(date) {const d = date || new Date();@@@ -93,6 +95,8 @@try {const el = document.documentElement;if (seasonal) el.setAttribute('data-ov-season', seasonal.id); else el.removeAttribute('data-ov-season');++ const marked = readDisplay().seasonal !== 'off' ? seasonNow() : null;++ if (marked) el.setAttribute('data-ov-season-mark', marked.id); else el.removeAttribute('data-ov-season-mark');el.setAttribute('data-theme', seasonal ? seasonal.theme : themeId);} catch { /* no DOM */ }if (changed) { try { window.dispatchEvent(new CustomEvent('ov:season', { detail: seasonal })); } catch { /* */ } }@@@ -170,6 +174,23 @@syncBrowserUi();}++ // ── A correction fades in ─────────────────────────────────++ // A theme that changes under someone (the account's answer, another tab) crossfades for 0.4 s instead of jumping;++ // first paint and their own picks stay instant, and calm motion (either preference) never fades.++ let _fadeTimer = 0;++ function fadeNext() {++ if (typeof document === 'undefined' || !document.head || readDisplay().motion === 'reduced') return;++ try { if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; } catch (e) { /* */ }++ const el = document.documentElement;++ if (!document.getElementById('ov-theme-fade-css')) {++ const st = document.createElement('style'); st.id = 'ov-theme-fade-css';++ st.textContent = 'html.ov-theme-fade,html.ov-theme-fade *,html.ov-theme-fade *::before,html.ov-theme-fade *::after{transition:background-color .4s ease,color .4s ease,border-color .4s ease,fill .4s ease,stroke .4s ease!important}';++ document.head.appendChild(st);++ }++ el.classList.add('ov-theme-fade');++ 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@@@ -278,7 +299,7 @@const headers = { 'Content-Type': 'application/json' };if (token) headers['Authorization'] = 'Bearer ' + token;fetch(_config.apiBase + '/api/themes/me', {-- method: 'PUT', headers: headers, credentials: 'include',++ method: 'PUT', headers: headers, credentials: 'include', keepalive: true,body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),}).catch(function () { /* offline / signed out — local cache still holds */ });}@@@ -331,7 +352,7 @@if (d.seasonal !== wasSeasonal && !(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', 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(d) }).catch(function () {});}return d;}@@@ -341,7 +362,7 @@// The icon link may be parsed after this script: repaint once the head is complete.if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { _browserUiKey = ''; syncBrowserUi(); });// A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.-- if (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) { applyDisplay(readDisplay()); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } });++ if (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { fadeNext(); resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) { fadeNext(); applyDisplay(readDisplay()); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } });// 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'@@@ -360,14 +381,16 @@// 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".++ let _lastSync = 0;function syncFromServer() {if (typeof fetch === 'undefined') return;++ _lastSync = Date.now();const token = authToken();const headers = token ? { 'Authorization': 'Bearer ' + token } : {};fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (data) {-- if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text, seasonal: data.display.seasonal || cur.seasonal }) !== JSON.stringify(cur)) setDisplay(data.display, { local: true }); }++ if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text, seasonal: data.display.seasonal || cur.seasonal }) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true }); } }if (!data || !data.theme_id) return;const serverId = data.theme_id;const custom = (data.custom_variables && typeof data.custom_variables === 'object'@@@ -377,6 +400,8 @@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++ if (_shown.id === serverId && !!_shown.custom === !!custom && JSON.stringify(_shown.vars) === JSON.stringify(vars)) return;++ fadeNext();show(serverId, vars, !!custom);};if (builtin) return settle(builtin);@@@ -385,6 +410,18 @@.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.++ if (typeof document !== 'undefined' && document.addEventListener) {++ const again = function () { if (Date.now() - _lastSync > 10000) syncFromServer(); };++ document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') again(); });++ if (typeof window !== 'undefined' && window.addEventListener) window.addEventListener('pageshow', function (e) { if (e && e.persisted) again(); });++ let from = '';++ try { from = document.referrer ? new URL(document.referrer).hostname : ''; } catch (e) { from = ''; }++ if (from && typeof location !== 'undefined' && from !== location.hostname) setTimeout(syncFromServer, 2500);++ }// ── Public API ───────────────────────────────────────────const OpenVibeThemeLoader = {
@@ -4,6 +4,32 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,32 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.12.0 — 2026-10-26
+
+- **The OpenVibe mark wears the season; the pumpkin button is gone.** The owner, 2026-10-25: the badge beside the brand
+ "takes up a lot of space … especially on mobile"; integrate it into the logo instead.
+ - **The mark's look** (`ov-mark.js`): `<html data-ov-season-mark="halloween|winter">` (set by `theme-loader.js` for
+ anyone who has not turned seasonal themes off, whatever their theme) dresses every mark.
+ - October: a pumpkin's stem, leaf and faint ribs, with the V as its grin.
+ - December: a cap of snow on the ring.
+ - **Moments:**
+ - The leaf unfurls when the mark plays its intro.
+ - Every 4 to 8 minutes a mark on screen flickers like a candle for a second.
+ - On the first visit of the day, a few leaves (snowflakes in December) drift down from the first mark seen.
+ - None of this runs under either reduced-motion setting.
+ - **Navbar:** no `.ovnav-season` badge. The switch stays in the account menu ("Seasonal themes") and the display box.
+- **Theme changes follow you between sites without a jolt** (`theme-loader.js`). The owner, 2026-10-25: a seasonal
+ toggle on Live, then Themes in the menu, showed openvibe.network in the old theme until a refresh.
+ - **Saves outlive the page:** both PUTs (`/api/themes/me`, `/api/themes/me/display`) are `keepalive`, so a choice made
+ just before following a link still lands.
+ - **The account is asked again:**
+ - when the page comes back into view (at most every 10 s);
+ - once more 2.5 s after arriving from another site, since that site's save may land after this page first asked.
+ - **Corrections fade:** a theme that changes under someone (the account's answer, another tab) crossfades for 0.4 s
+ instead of jumping. An answer that changes nothing repaints nothing. Calm motion never fades.
+- **Your name in the account menu opens your profile.** The avatar and name at the top of the menu link to
+ `openvibe.network/@username` (an anonymous visitor's to `/my`). They were plain text, and pressing them did nothing.
+
## 4.11.0 — 2026-10-25
## 4.11.0 — 2026-10-25
- **Seasonal themes.** The owner, 2026-10-24: a Halloween theme and style on every site, as the default during
- **Seasonal themes.** The owner, 2026-10-24: a Halloween theme and style on every site, as the default during
@@ -61,10 +61,6 @@
@@ -61,10 +61,6 @@
.ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 2px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
.ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 2px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
.ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
.ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
.ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }
.ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }
- /* The season's badge (theme-loader.js Seasons): pressed while its theme shows, faded when seasonal themes are off. */
- .ovnav-season { all: unset; box-sizing: border-box; cursor: pointer; width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 2px; }
- .ovnav-season:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
- .ovnav-season[aria-pressed="false"] { opacity: .5; filter: grayscale(1); }
.ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }
.ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }
.ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
.ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
@@ -85,6 +81,9 @@
@@ -85,6 +81,9 @@
.ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }
.ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }
.openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }
.openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }
.openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }
.openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }
+ .openvibe-navbar-dropdown-header a.ud-me { color: inherit; text-decoration: none; display: block; line-height: 0; }
+ .openvibe-navbar-dropdown-header a.name.ud-me { line-height: inherit; display: block; }
+ .openvibe-navbar-dropdown-header a.name.ud-me:hover, .openvibe-navbar-dropdown-header a.name.ud-me:focus-visible { text-decoration: underline; }
.openvibe-navbar-dropdown .ovnav-chip--menu { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font: 700 12px/1.2 inherit; color: var(--chip-c, #fbbf24); background: color-mix(in srgb, var(--chip-c, #fbbf24) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, #fbbf24) 34%, transparent); }
.openvibe-navbar-dropdown .ovnav-chip--menu { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font: 700 12px/1.2 inherit; color: var(--chip-c, #fbbf24); background: color-mix(in srgb, var(--chip-c, #fbbf24) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, #fbbf24) 34%, transparent); }
.openvibe-navbar .ovnav-burger { all: unset; box-sizing: border-box; cursor: pointer; display: none; width: 38px; height: 38px; border-radius: 11px; flex-direction: column; align-items: center; justify-content: center; gap: 4px; flex: none; }
.openvibe-navbar .ovnav-burger { all: unset; box-sizing: border-box; cursor: pointer; display: none; width: 38px; height: 38px; border-radius: 11px; flex-direction: column; align-items: center; justify-content: center; gap: 4px; flex: none; }
.openvibe-navbar .ovnav-burger span { display: block; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transition: transform .2s, opacity .2s; }
.openvibe-navbar .ovnav-burger span { display: block; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transition: transform .2s, opacity .2s; }
@@ -531,28 +530,12 @@
@@ -531,28 +530,12 @@
return { sub: brand.href, core: brand.joined ? apex : 'https://openvibe.network/', tld: apex, apex };
return { sub: brand.href, core: brand.joined ? apex : 'https://openvibe.network/', tld: apex, apex };
}
}
- // ── The season's badge beside the brand ─────────────────────────────────────────
- // While a season runs (theme-loader.js SEASONS) the bar shows its emblem; a click flips the `seasonal` display
- // preference (on a theme of their own, it opens the themes page).
- const SEASON_ICON = {
- halloween: '<path d="M12 7c-1.3-1.5-4.7-1.7-6.5.3-1.9 2.1-2 6.8-.2 9.6 1.6 2.4 4.6 2.8 6.7 1.5 2.1 1.3 5.1.9 6.7-1.5 1.8-2.8 1.7-7.5-.2-9.6C17 5.3 13.4 5.5 12 7Z" fill="#ff7a1a"/><path d="M12 7c0-1.7.5-3 2-3.6" stroke="#62b33a" stroke-width="2" fill="none"/>',
- winter: '<path d="M12 3v18M4.2 7.5l15.6 9M4.2 16.5l15.6-9" stroke="#7ee8da" stroke-width="2" stroke-linecap="round"/>',
- };
+ // ── The season (theme-loader.js Seasons) ────────────────────────────────────────
+ // The OpenVibe mark wears it (ov-mark.js); the menus carry the switch (the account menu's "Seasonal themes" row and
+ // the display box). → { s, on, off } during a season, else null.
function seasonState() {
function seasonState() {
const L = root.OpenVibeThemeLoader, s = L && L.seasons && L.seasons.now();
const L = root.OpenVibeThemeLoader, s = L && L.seasons && L.seasons.now();
- return s && SEASON_ICON[s.id] ? { s, on: !!L.seasons.showing(), off: L.display.get().seasonal === 'off' } : null;
- }
- const seasonTitle = (st) => (st.on ? `${st.s.name} theme on: select to turn seasonal themes off` : `Seasonal themes${st.off ? ' off: select to turn them on' : ': pick the default theme to see them'}`);
- function seasonBadgeHTML() {
- const st = seasonState();
- return st ? `<button type="button" class="ovnav-season" aria-pressed="${st.on}" title="${seasonTitle(st)}" aria-label="${seasonTitle(st)}"><svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">${SEASON_ICON[st.s.id]}</svg></button>` : '';
- }
- function bindSeasonBadge(nav) {
- if (!seasonState()) return;
- const btn = nav.querySelector('.ovnav-season'), L = root.OpenVibeThemeLoader;
- const paint = () => { const st = seasonState(); if (st) { btn.setAttribute('aria-pressed', String(st.on)); btn.title = seasonTitle(st); btn.setAttribute('aria-label', btn.title); } };
- btn.addEventListener('click', () => { const st = seasonState(); if (st.on || st.off) L.display.set({ seasonal: st.on ? 'off' : 'on' }); else window.location.href = 'https://openvibe.network/themes'; paint(); });
- if (root.addEventListener) root.addEventListener('ov:season', paint, onDocument());
+ return s ? { s, on: !!L.seasons.showing(), off: L.display.get().seasonal === 'off' } : null;
}
}
function brandHTML(brand) {
function brandHTML(brand) {
@@ -570,7 +553,6 @@
@@ -570,7 +553,6 @@
<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
</div>
</div>
- ${seasonBadgeHTML()}
${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
}
}
@@ -1433,7 +1415,6 @@
@@ -1433,7 +1415,6 @@
bindLinks(nav, links);
bindLinks(nav, links);
bindChips(nav);
bindChips(nav);
bindLauncher(nav);
bindLauncher(nav);
- bindSeasonBadge(nav);
countView();
countView();
// Pages that wire the bell themselves run right after init(); give them the first go.
// Pages that wire the bell themselves run right after init(); give them the first go.
setTimeout(() => mountBell(nav, u), 0);
setTimeout(() => mountBell(nav, u), 0);
@@ -1445,15 +1426,17 @@
@@ -1445,15 +1426,17 @@
dropdown.id = 'openvibe-user-dropdown';
dropdown.id = 'openvibe-user-dropdown';
const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
+ // The avatar and name lead to the person's profile (an anonymous visitor's account page): people press them.
+ const meHref = !isAnon && u.username ? `https://openvibe.network/@${encodeURIComponent(u.username)}` : 'https://openvibe.network/my';
const menuCfg = _config.menu || {};
const menuCfg = _config.menu || {};
const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);
const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);
const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);
const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);
dropdown.innerHTML = `
dropdown.innerHTML = `
<div class="openvibe-navbar-dropdown-header">
<div class="openvibe-navbar-dropdown-header">
- ${avatarImg(u, 72, '', '')}
+ <a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a>
<div class="info">
<div class="info">
- <div class="name">${escapeAttr(u.display_name || u.username)}</div>
+ <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeAttr(u.display_name || u.username)}</a>
<div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>
<div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>
${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}
${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
@@ -16,6 +16,11 @@
@@ -16,6 +16,11 @@
* so it keeps its look. It never moves while the page is hidden, while it is off-screen
* so it keeps its look. It never moves while the page is hidden, while it is off-screen
* (IntersectionObserver), with data-static, under prefers-reduced-motion or under the network's
* (IntersectionObserver), with data-static, under prefers-reduced-motion or under the network's
* own reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js).
* own reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js).
+ *
+ * The season (theme-loader.js sets <html data-ov-season-mark="halloween|winter"> for anyone who has not turned seasonal
+ * themes off, on any theme): the mark wears it, a pumpkin's stem, leaf and ribs in October, a cap of snow in December.
+ * Now and then (every 4 to 8 minutes) a mark on screen flickers for a moment, and on the first visit of the day a few
+ * leaves (snowflakes in December) drift down from the first mark seen. Never under either reduced-motion setting.
*/
*/
(function () {
(function () {
'use strict';
'use strict';
@@ -73,15 +78,37 @@
@@ -73,15 +78,37 @@
@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}
@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}
@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}
@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}
@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}
@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}
-@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d{animation:none!important}.ov-mark .o{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;
+.ov-mark .sh,.ov-mark .sw{display:none}
+html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw{display:inline}
+.ov-mark .sr{fill:none;stroke:currentColor;stroke-width:2;opacity:.28}
+.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:3.2;stroke-linecap:round}
+.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 70%}
+.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:5.4;stroke-linecap:round}
+.ov-mark .sd{fill:#f4fbff}
+html[data-ov-season-mark=halloween] .ov-mark .o{fill:#ffc27a}
+html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) both}
+.ov-mark[data-ovm-flourish] .sr{animation:ovmFlicker 1.6s linear 1}
+.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}
+.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
+.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:2147483000}
+.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}
+html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
+@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}
+@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
+@keyframes ovmFlicker{0%,100%{opacity:.28}12%{opacity:.7}22%{opacity:.2}38%{opacity:.8}55%{opacity:.35}72%{opacity:.65}}
+@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
+@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}80%{opacity:.9}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
+@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d,.ov-mark .sl,.ov-mark .sr,.ov-mark .sd{animation:none!important}.ov-mark .o,.ovm-fall{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;
function svg(id) {
function svg(id) {
return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
-<circle class="p" cx="24" cy="24" r="18"/>
+<circle class="p" cx="24" cy="24" r="18"/><g class="sh"><path class="sr" d="M24 6.5C16.5 12 16.5 36 24 41.5M24 6.5C31.5 12 31.5 36 24 41.5"/></g>
<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
+<g class="sh"><path class="ss" d="M23.4 7.4c.2-2.6 1-4.4 2.6-5.6"/><path class="sl" d="M25.8 3.6c2.2-2.4 5.6-2.6 8-1.2-1.8 2.6-5.2 3.4-8 1.2z"/></g>
+<g class="sw"><path class="sc" d="M12.4 10.2A18 18 0 0 1 35.6 10.2"/><circle class="sd" cx="16.6" cy="9.8" r="1.6"/><circle class="sd" cx="30.6" cy="9.4" r="1.3"/></g>
<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
}
}
@@ -106,7 +133,7 @@
@@ -106,7 +133,7 @@
if (!el.isConnected || !svgEl) { forget(el); return; }
if (!el.isConnected || !svgEl) { forget(el); return; }
const now = Date.now();
const now = Date.now();
const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
- if (may && !st.introEnd) st.introEnd = introDeadline(now);
+ if (may && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
const s = el.getAttribute('data-state');
const s = el.getAttribute('data-state');
const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');
const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');
if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
@@ -132,6 +159,39 @@
@@ -132,6 +159,39 @@
try { el.querySelector('svg').pauseAnimations(); } catch { /* */ }
try { el.querySelector('svg').pauseAnimations(); } catch { /* */ }
}
}
function updateAll() { document.querySelectorAll('.ov-mark[data-ov]').forEach(update); }
function updateAll() { document.querySelectorAll('.ov-mark[data-ov]').forEach(update); }
+ // ── The season ──
+ const season = () => document.documentElement.getAttribute('data-ov-season-mark');
+ let flourishTimer = 0;
+ function scheduleFlourish() {
+ clearTimeout(flourishTimer); flourishTimer = 0;
+ if (!season()) return;
+ flourishTimer = setTimeout(() => {
+ if (season() && !reduced() && !document.hidden) {
+ document.querySelectorAll('.ov-mark[data-ov]:not([data-static])').forEach((el) => {
+ const st = state.get(el); if (!st || st.onScreen === false) return;
+ el.setAttribute('data-ovm-flourish', ''); setTimeout(() => el.removeAttribute('data-ovm-flourish'), 1700);
+ });
+ }
+ scheduleFlourish();
+ }, (4 + Math.random() * 4) * 60000);
+ }
+ const FALL_KEY = 'ov-season-fall';
+ function fallOnce(el) {
+ const id = season(); if (!id || reduced() || document.hidden || !el.isConnected) return;
+ const day = id + ':' + new Date().toDateString();
+ try { if (localStorage.getItem(FALL_KEY) === day) return; localStorage.setItem(FALL_KEY, day); } catch { return; }
+ const r = el.getBoundingClientRect(); if (!r.width || !document.body) return;
+ const box = document.createElement('div'); box.className = 'ovm-fall'; box.setAttribute('aria-hidden', 'true');
+ box.style.left = (r.left + r.width / 2) + 'px'; box.style.top = (r.top + r.height * 0.6) + 'px';
+ const colors = id === 'winter' ? ['#f4fbff', '#d8f3ff', '#ffffff'] : ['#ff7a1a', '#f5a524', '#d9481f', '#7fd04e'];
+ for (let i = 0; i < 7; i++) {
+ const s = document.createElement('i');
+ s.style.cssText = `--x:${Math.round((Math.random() - 0.3) * 140)}px;--y:${Math.round(120 + Math.random() * 160)}px;--r:${Math.round(Math.random() * 540 - 270)}deg;animation-delay:${(i * 0.18 + Math.random() * 0.2).toFixed(2)}s;animation-duration:${(2.4 + Math.random() * 1.2).toFixed(2)}s;background:${colors[i % colors.length]}`;
+ box.appendChild(s);
+ }
+ document.body.appendChild(box);
+ setTimeout(() => box.remove(), 5200);
+ }
const bound = new WeakSet();
const bound = new WeakSet();
function track(el) {
function track(el) {
state.set(el, { introEnd: 0, lingerEnd: 0, hover: false, focus: false, onScreen: io ? null : true, timer: 0 });
state.set(el, { introEnd: 0, lingerEnd: 0, hover: false, focus: false, onScreen: io ? null : true, timer: 0 });
@@ -171,11 +231,12 @@
@@ -171,11 +231,12 @@
try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }
try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }
document.addEventListener('visibilitychange', updateAll);
document.addEventListener('visibilitychange', updateAll);
mount();
mount();
+ scheduleFlourish();
try {
try {
new MutationObserver((records) => {
new MutationObserver((records) => {
let added = false;
let added = false;
for (const r of records) {
for (const r of records) {
- if (r.type === 'attributes') { if (r.target === document.documentElement) updateAll(); else if (state.has(r.target)) update(r.target); continue; }
+ if (r.type === 'attributes') { if (r.target === document.documentElement) { updateAll(); if (r.attributeName === 'data-ov-season-mark') scheduleFlourish(); } else if (state.has(r.target)) update(r.target); continue; }
if (r.addedNodes.length) added = true;
if (r.addedNodes.length) added = true;
r.removedNodes.forEach((node) => {
r.removedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
if (node.nodeType !== 1) return;
@@ -184,7 +245,7 @@
@@ -184,7 +245,7 @@
});
});
}
}
if (added) mount();
if (added) mount();
- }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion'] });
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion', 'data-ov-season-mark'] });
} catch { /* */ }
} catch { /* */ }
}
}
window.ovMarkMount = mount;
window.ovMarkMount = mount;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.11.0",
+ "version": "4.12.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.11.0",
+ "version": "4.12.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.11.0",
+ "version": "4.12.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": {
@@ -204,31 +204,23 @@ function load(hostname, opts = {}) {
@@ -204,31 +204,23 @@ function load(hostname, opts = {}) {
assert.ok(dd && dd.innerHTML.includes('data-menu-id="mp"') && dd.innerHTML.includes('My pastes'), 'custom dropdown row rendered');
assert.ok(dd && dd.innerHTML.includes('data-menu-id="mp"') && dd.innerHTML.includes('My pastes'), 'custom dropdown row rendered');
assert.ok(dd.innerHTML.includes('/my#history'), 'History link present');
assert.ok(dd.innerHTML.includes('/my#history'), 'History link present');
assert.ok(dd.innerHTML.includes('href="https://openvibe.network/@x"') && dd.innerHTML.includes('My Profile'), 'the public profile (openvibe.network/@username) is one tap from the account menu');
assert.ok(dd.innerHTML.includes('href="https://openvibe.network/@x"') && dd.innerHTML.includes('My Profile'), 'the public profile (openvibe.network/@username) is one tap from the account menu');
+ assert.ok(dd.innerHTML.includes('<a class="name ud-me" href="https://openvibe.network/@x"'), 'pressing your name in the menu opens your profile');
+ assert.ok(dd.innerHTML.includes('<a class="ud-me" href="https://openvibe.network/@x" tabindex="-1" aria-hidden="true"><img'), 'and so does your picture (one stop for keyboards)');
const id = navbar.addMenuItem({ label: 'Runtime row', href: '/r', position: 'before' });
const id = navbar.addMenuItem({ label: 'Runtime row', href: '/r', position: 'before' });
assert.ok(id, 'addMenuItem returns an id');
assert.ok(id, 'addMenuItem returns an id');
}
}
-// ── the season's badge (theme-loader.js Seasons): beside the brand during a season, pressed when its theme shows,
-// faded once seasonal themes are off; no season, no badge ────
+// ── the season lives in the mark (ov-mark.js), not in a badge beside the brand; the switch stays in the account menu ────
{
{
const HALLOWEEN = { id: 'halloween', name: 'Halloween', theme: 'halloween' };
const HALLOWEEN = { id: 'halloween', name: 'Halloween', theme: 'halloween' };
- const loader = (showing, seasonal) => ({
- seasons: { now: () => HALLOWEEN, showing: () => (showing ? HALLOWEEN : null) },
- display: { get: () => ({ text: '100', motion: 'auto', seasonal }), set() {}, options: { text: ['100'], motion: ['auto'], seasonal: ['on', 'off'] } },
- });
- let { navbar, created } = load('openvibe.live');
- global.OpenVibeThemeLoader = loader(true, 'on');
- navbar.init({ service: 'live' });
- let nav = created.find((e) => e.tag === 'nav');
- assert.ok(/<button type="button" class="ovnav-season" aria-pressed="true" title="Halloween theme on: select to turn seasonal themes off"/.test(nav.innerHTML), 'the pumpkin, pressed');
- ({ navbar, created } = load('openvibe.live'));
- global.OpenVibeThemeLoader = loader(false, 'off');
- navbar.init({ service: 'live' });
- nav = created.find((e) => e.tag === 'nav');
- assert.ok(/class="ovnav-season" aria-pressed="false" title="Seasonal themes off: select to turn them on"/.test(nav.innerHTML), 'faded once turned off');
- ({ navbar, created } = load('openvibe.live'));
- global.OpenVibeThemeLoader = { ...loader(false, 'on'), seasons: { now: () => null, showing: () => null } };
+ global.OpenVibeThemeLoader = {
+ seasons: { now: () => HALLOWEEN, showing: () => HALLOWEEN },
+ display: { get: () => ({ text: '100', motion: 'auto', seasonal: 'on' }), set() {}, options: { text: ['100'], motion: ['auto'], seasonal: ['on', 'off'] } },
+ };
+ const { navbar, created } = load('openvibe.live');
navbar.init({ service: 'live' });
navbar.init({ service: 'live' });
- assert.ok(!created.find((e) => e.tag === 'nav').innerHTML.includes('ovnav-season'), 'no season, no badge');
+ const nav = created.find((e) => e.tag === 'nav');
+ assert.ok(!nav.innerHTML.includes('ovnav-season'), 'no pumpkin button beside the brand');
+ assert.ok(nav.innerHTML.includes('class="ov-mark"'), 'the mark, which wears the season itself');
delete global.OpenVibeThemeLoader;
delete global.OpenVibeThemeLoader;
}
}
console.log('navbar brand: all checks passed');
console.log('navbar brand: all checks passed');
@@ -13,11 +13,11 @@ const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8
@@ -13,11 +13,11 @@ const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8
const by = Object.fromEntries(BUILTIN_THEMES.map((t) => [t.id, t]));
const by = Object.fromEntries(BUILTIN_THEMES.map((t) => [t.id, t]));
/** A page load on `day` (YYYY-MM-DD, local) with `store` as localStorage and `cookie`. → { L, html, events, puts } */
/** A page load on `day` (YYYY-MM-DD, local) with `store` as localStorage and `cookie`. → { L, html, events, puts } */
-function load(day, store = new Map(), cookie = '') {
+function load(day, store = new Map(), cookie = '', opts = {}) {
const [y, m, d] = day.split('-').map(Number);
const [y, m, d] = day.split('-').map(Number);
const fixed = new Date(y, m - 1, d, 12).getTime();
const fixed = new Date(y, m - 1, d, 12).getTime();
class FixedDate extends Date { constructor(...a) { if (a.length) super(...a); else super(fixed); } static now() { return fixed; } }
class FixedDate extends Date { constructor(...a) { if (a.length) super(...a); else super(fixed); } static now() { return fixed; } }
- const attrs = new Map(); const props = new Map(); const events = []; const puts = [];
+ const attrs = new Map(); const props = new Map(); const events = []; const puts = []; const gets = []; const timers = []; const docListeners = {};
const html = {
const html = {
style: { setProperty: (k, v) => props.set(k, v), removeProperty: (k) => props.delete(k) },
style: { setProperty: (k, v) => props.set(k, v), removeProperty: (k) => props.delete(k) },
setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k),
setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k),
@@ -26,11 +26,17 @@ function load(day, store = new Map(), cookie = '') {
@@ -26,11 +26,17 @@ function load(day, store = new Map(), cookie = '') {
const listeners = {};
const listeners = {};
const sandbox = {
const sandbox = {
Date: FixedDate, JSON, Object, String, Number, Math, Array, Promise, Error,
Date: FixedDate, JSON, Object, String, Number, Math, Array, Promise, Error,
- document: { documentElement: html, readyState: 'complete', cookie, head: null, querySelector: () => null, getElementById: () => null, addEventListener() {} },
+ document: { documentElement: html, readyState: 'complete', cookie, head: null, querySelector: () => null, getElementById: () => null, referrer: opts.referrer || '', visibilityState: 'visible',
+ addEventListener: (type, fn) => { (docListeners[type] = docListeners[type] || []).push(fn); } },
+ URL, setTimeout: (fn, ms) => timers.push({ fn, ms }), clearTimeout() {},
localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
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: 'openvibe.live', protocol: 'https:' },
location: { hostname: 'openvibe.live', protocol: 'https:' },
getComputedStyle: () => ({ getPropertyValue: () => '' }),
getComputedStyle: () => ({ getPropertyValue: () => '' }),
- fetch: (url, o) => { if (o && o.method === 'PUT') puts.push({ url, body: JSON.parse(o.body) }); return Promise.resolve({ ok: false, json: () => Promise.resolve(null) }); },
+ fetch: (url, o) => {
+ if (o && o.method === 'PUT') { puts.push({ url, body: JSON.parse(o.body), keepalive: o.keepalive }); return Promise.resolve({ ok: false, json: () => Promise.resolve(null) }); }
+ gets.push(url);
+ return Promise.resolve(opts.active ? { ok: true, json: () => Promise.resolve(opts.active) } : { ok: false, json: () => Promise.resolve(null) });
+ },
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
dispatchEvent: (e) => { events.push(e); },
dispatchEvent: (e) => { events.push(e); },
@@ -38,7 +44,7 @@ function load(day, store = new Map(), cookie = '') {
@@ -38,7 +44,7 @@ function load(day, store = new Map(), cookie = '') {
sandbox.window = sandbox;
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
sandbox.globalThis = sandbox;
vm.runInNewContext(SRC, sandbox);
vm.runInNewContext(SRC, sandbox);
- return { L: sandbox.OpenVibeThemeLoader, html, events, puts, store, fire: (type, e) => (listeners[type] || []).forEach((fn) => fn(e)) };
+ return { L: sandbox.OpenVibeThemeLoader, html, events, puts, gets, timers, store, fire: (type, e) => (listeners[type] || []).forEach((fn) => fn(e)), fireDoc: (type) => (docListeners[type] || []).forEach((fn) => fn()) };
}
}
const accent = (p) => p.html.props.get('--accent');
const accent = (p) => p.html.props.get('--accent');
@@ -104,4 +110,36 @@ p = load('2026-12-10');
@@ -104,4 +110,36 @@ p = load('2026-12-10');
assert.strictEqual(accent(p), by.frost.variables['--accent']);
assert.strictEqual(accent(p), by.frost.variables['--accent']);
assert.strictEqual(p.html.getAttribute('data-ov-season'), 'winter');
assert.strictEqual(p.html.getAttribute('data-ov-season'), 'winter');
-console.log('seasonal-themes: all checks passed');
+// 7. The mark wears the season for anyone who has not turned seasonal themes off, whatever their theme.
+assert.strictEqual(load('2026-10-24').html.getAttribute('data-ov-season-mark'), 'halloween');
+p = load('2026-10-24', new Map([['ov_theme', JSON.stringify({ id: 'nord', variables: by.nord.variables })]]));
+assert.deepStrictEqual([p.html.getAttribute('data-ov-season'), p.html.getAttribute('data-ov-season-mark')], [null, 'halloween'], 'a picked theme keeps its colours; the mark still wears October');
+p = load('2026-10-24');
+p.L.display.set({ seasonal: 'off' });
+assert.strictEqual(p.html.getAttribute('data-ov-season-mark'), null, 'seasonal off: no seasonal mark either');
+assert.strictEqual(load('2026-11-05').html.getAttribute('data-ov-season-mark'), null);
+assert.strictEqual(load('2026-12-10').html.getAttribute('data-ov-season-mark'), 'winter');
+
+// 8. Saves outlive the page (a choice made just before following a link to another site still lands).
+p = load('2026-10-24', new Map(), 'ov_sso_hint=account');
+p.L.display.set({ seasonal: 'off' });
+p.L.save('nord');
+assert.ok(p.puts.length === 2 && p.puts.every((x) => x.keepalive === true), 'both saves are keepalive');
+
+// 9. The account is asked again when the page comes back into view (not more than every 10 s), and once more 2.5 s after
+// arriving from another site; its answer corrects the theme.
+p = load('2026-10-24');
+assert.strictEqual(p.gets.filter((u) => /\/api\/themes\/me\/active$/.test(u)).length, 1, 'asked on load');
+p.fireDoc('visibilitychange');
+assert.strictEqual(p.gets.length, 1, 'not again within 10 s');
+assert.ok(!p.timers.some((t) => t.ms === 2500), 'no extra check without a referrer from another site');
+p = load('2026-10-24', new Map(), '', { referrer: 'https://openvibe.network/themes', active: { theme_id: 'nord' } });
+const again = p.timers.find((t) => t.ms === 2500);
+assert.ok(again, 'arriving from openvibe.network: one more check soon');
+again.fn();
+assert.strictEqual(p.gets.length, 2);
+assert.ok(!load('2026-10-24', new Map(), '', { referrer: 'https://openvibe.live/other' }).timers.some((t) => t.ms === 2500), 'from this same site: no extra check');
+setImmediate(() => {
+ assert.strictEqual(accent(p), by.nord.variables['--accent'], "the account's theme replaces the stale copy");
+ console.log('seasonal-themes: all checks passed');
+});
@@ -70,6 +70,8 @@
@@ -70,6 +70,8 @@
// on the default who has not turned seasonal themes off: the display preference `seasonal` (on|off), saved to the
// 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>
// 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).
// 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>">.
const DEFAULT_ID = 'vibe';
const DEFAULT_ID = 'vibe';
function seasonNow(date) {
function seasonNow(date) {
const d = date || new Date();
const d = date || new Date();
@@ -93,6 +95,8 @@
@@ -93,6 +95,8 @@
try {
try {
const el = document.documentElement;
const el = document.documentElement;
if (seasonal) el.setAttribute('data-ov-season', seasonal.id); else el.removeAttribute('data-ov-season');
if (seasonal) el.setAttribute('data-ov-season', seasonal.id); else el.removeAttribute('data-ov-season');
+ const marked = readDisplay().seasonal !== 'off' ? seasonNow() : null;
+ if (marked) el.setAttribute('data-ov-season-mark', marked.id); else el.removeAttribute('data-ov-season-mark');
el.setAttribute('data-theme', seasonal ? seasonal.theme : themeId);
el.setAttribute('data-theme', seasonal ? seasonal.theme : themeId);
} catch { /* no DOM */ }
} catch { /* no DOM */ }
if (changed) { try { window.dispatchEvent(new CustomEvent('ov:season', { detail: seasonal })); } catch { /* */ } }
if (changed) { try { window.dispatchEvent(new CustomEvent('ov:season', { detail: seasonal })); } catch { /* */ } }
@@ -170,6 +174,23 @@
@@ -170,6 +174,23 @@
syncBrowserUi();
syncBrowserUi();
}
}
+ // ── A correction fades in ─────────────────────────────────
+ // A theme that changes under someone (the account's answer, another tab) crossfades for 0.4 s instead of jumping;
+ // first paint and their own picks stay instant, and calm motion (either preference) never fades.
+ let _fadeTimer = 0;
+ function fadeNext() {
+ if (typeof document === 'undefined' || !document.head || readDisplay().motion === 'reduced') return;
+ try { if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; } catch (e) { /* */ }
+ const el = document.documentElement;
+ if (!document.getElementById('ov-theme-fade-css')) {
+ const st = document.createElement('style'); st.id = 'ov-theme-fade-css';
+ st.textContent = 'html.ov-theme-fade,html.ov-theme-fade *,html.ov-theme-fade *::before,html.ov-theme-fade *::after{transition:background-color .4s ease,color .4s ease,border-color .4s ease,fill .4s ease,stroke .4s ease!important}';
+ document.head.appendChild(st);
+ }
+ el.classList.add('ov-theme-fade');
+ 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 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
// 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
// applied, so a violet, light or custom theme gets a matching favicon and address bar. Runs on
@@ -278,7 +299,7 @@
@@ -278,7 +299,7 @@
const headers = { 'Content-Type': 'application/json' };
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
if (token) headers['Authorization'] = 'Bearer ' + token;
fetch(_config.apiBase + '/api/themes/me', {
fetch(_config.apiBase + '/api/themes/me', {
- method: 'PUT', headers: headers, credentials: 'include',
+ method: 'PUT', headers: headers, credentials: 'include', keepalive: true,
body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),
body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),
}).catch(function () { /* offline / signed out — local cache still holds */ });
}).catch(function () { /* offline / signed out — local cache still holds */ });
}
}
@@ -331,7 +352,7 @@
@@ -331,7 +352,7 @@
if (d.seasonal !== wasSeasonal && !(opts && opts.noRepaint)) show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom);
if (d.seasonal !== wasSeasonal && !(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', 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(d) }).catch(function () {});
}
}
return d;
return d;
}
}
@@ -341,7 +362,7 @@
@@ -341,7 +362,7 @@
// The icon link may be parsed after this script: repaint once the head is complete.
// The icon link may be parsed after this script: repaint once the head is complete.
if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { _browserUiKey = ''; syncBrowserUi(); });
if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { _browserUiKey = ''; syncBrowserUi(); });
// A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.
// A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.
- if (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) { applyDisplay(readDisplay()); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } });
+ if (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { fadeNext(); resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) { fadeNext(); applyDisplay(readDisplay()); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } });
// Look up a theme we don't ship in the built-in map (community/custom themes) from the
// 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'
// central catalog. Without this, any non-built-in id fell through to the default 'vibe'
@@ -360,14 +381,16 @@
@@ -360,14 +381,16 @@
// along via credentials:'include' (openvibe.network allows credentialed CORS per origin),
// 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
// so this now works on sites where the cookie isn't readable from JS — previously the sync
// silently never ran there and the user's theme appeared to be "lost".
// silently never ran there and the user's theme appeared to be "lost".
+ let _lastSync = 0;
function syncFromServer() {
function syncFromServer() {
if (typeof fetch === 'undefined') return;
if (typeof fetch === 'undefined') return;
+ _lastSync = Date.now();
const token = authToken();
const token = authToken();
const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })
fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
.then(function (data) {
- if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text, seasonal: data.display.seasonal || cur.seasonal }) !== JSON.stringify(cur)) setDisplay(data.display, { local: true }); }
+ if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text, seasonal: data.display.seasonal || cur.seasonal }) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true }); } }
if (!data || !data.theme_id) return;
if (!data || !data.theme_id) return;
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'
@@ -377,6 +400,8 @@
@@ -377,6 +400,8 @@
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); // cacheLocally, not save() — no write-back loop
+ if (_shown.id === serverId && !!_shown.custom === !!custom && JSON.stringify(_shown.vars) === JSON.stringify(vars)) return;
+ fadeNext();
show(serverId, vars, !!custom);
show(serverId, vars, !!custom);
};
};
if (builtin) return settle(builtin);
if (builtin) return settle(builtin);
@@ -385,6 +410,18 @@
@@ -385,6 +410,18 @@
.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.
+ if (typeof document !== 'undefined' && document.addEventListener) {
+ const again = function () { if (Date.now() - _lastSync > 10000) syncFromServer(); };
+ document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') again(); });
+ if (typeof window !== 'undefined' && window.addEventListener) window.addEventListener('pageshow', function (e) { if (e && e.persisted) again(); });
+ let from = '';
+ try { from = document.referrer ? new URL(document.referrer).hostname : ''; } catch (e) { from = ''; }
+ if (from && typeof location !== 'undefined' && from !== location.hostname) setTimeout(syncFromServer, 2500);
+ }
// ── Public API ───────────────────────────────────────────
// ── Public API ───────────────────────────────────────────
const OpenVibeThemeLoader = {
const OpenVibeThemeLoader = {