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)

OP OpenVibers committed 19 days ago · 7c665ffb23 · 8 files, +202 −65

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 85d5b73..2a9180b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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
ddiff --git a/navbar.js b/navbar.js
iindex 83684e4..f905851 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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>` : ''}
ddiff --git a/ov-mark.js b/ov-mark.js
iindex e3ac07f..dfa5b0d 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 7486403..8145360 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex f2dce45..e8a89cf 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex e50fa50..f95bd57 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -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');
ddiff --git a/test/seasonal-themes.test.js b/test/seasonal-themes.test.js
iindex 35aaa40..41e3269 100644
---- a/test/seasonal-themes.test.js
++++ b/test/seasonal-themes.test.js
@@@ -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]));
/** 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');
++});
ddiff --git a/theme-loader.js b/theme-loader.js
iindex aa25516..09977a0 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -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 = {