openvibers/openvibe.shared Commits

Seasonal themes: Pumpkin Night in October and Frost in December stand in for the default theme, with a seasonal on/off display preference and the season's badge in the navbar (4.11.0) (#101)

OP OpenVibers committed 19 days ago · 9fae8332b2 · 10 files, +339 −30

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8bdefff..85d5b73 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.11.0 — 2026-10-25
++
++- **Seasonal themes.** The owner, 2026-10-24: a Halloween theme and style on every site, as the default during
++ October, part of a seasonal system that turns on by date, with an opt-out for anyone who wants their normal
++ default.
++ - **Two seasonal themes** (`builtin-themes.js`):
++ - Pumpkin Night (`halloween`): pumpkin orange on a violet night, October 1–31;
++ - Frost (`frost`): frosted teal on a midnight sky, December 1–31.
++ - **How they turn on.** A theme with `season: { id, name, from, to }` (MM-DD, both days included, the visitor's own
++ calendar) is also listed in `SEASONS`, and `seasonOn(date)` finds the season for a date. A season may run over New
++ Year.
++ - **What the loader paints** (`theme-loader.js`, Seasons):
++ - Anyone on the default theme sees the season's theme in its place while it runs. A theme someone picked, or a
++ customised default, stays theirs.
++ - `<html>` carries `data-ov-season="<id>"` and `data-theme` names the theme on screen. The saved choice
++ (`getCurrent()`) stays the person's.
++ - `window` gets `ov:season` with `{ id, name, theme }` or `null`.
++ - API: `OpenVibeThemeLoader.seasons.{ SEASONS, now(), showing() }`.
++ - **The opt-out** is a display preference, `seasonal: on|off` (on by default). It repaints at once and in other tabs,
++ and is saved to the account with text size and motion (`PUT /api/themes/me/display`). Network stores it once it
++ accepts the key; until then it stays in the browser. Picking the default theme during a season turns seasonal
++ themes off: "my normal theme".
++ - **The navbar** shows the season's emblem beside the brand while a season runs: a pumpkin, or a snowflake in
++ December. It is pressed while the season's theme shows and faded when seasonal themes are off, and a click flips
++ it. The account menu's Display section and the launcher's display row get a "Seasonal themes" switch.
++
## 4.10.0 — 2026-10-24
- **items.css draws `name-fx-twofat`, "2fat Bass Drop"**, the one-of-a-kind name effect the owner asked for 2fat on
ddiff --git a/builtin-themes.js b/builtin-themes.js
iindex a94db9b..14f21ea 100644
---- a/builtin-themes.js
++++ b/builtin-themes.js
@@@ -893,6 +893,72 @@ const RAW_BUILTIN_THEMES = [
'--info': '#00ffff',
},
},
++ // ── Seasonal themes ───────────────────────────────────────────────────────────────────────────
++ // A theme with `season` is also the network's default between `from` and `to` (MM-DD, the visitor's own
++ // calendar, both days included) for everyone on the default theme who has not turned seasonal themes off
++ // (theme-loader.js; display preference `seasonal`). Anyone can also pick one like any other theme, any time.
++ {
++ name: 'Pumpkin Night',
++ slug: 'halloween',
++ mode: 'dark',
++ description: 'Halloween: pumpkin orange on a haunted violet night. On by itself through October.',
++ tags: ['seasonal', 'halloween', 'orange', 'purple'],
++ season: { id: 'halloween', name: 'Halloween', from: '10-01', to: '10-31' },
++ variables: {
++ '--bg-primary': '#0e0a14',
++ '--bg-secondary': '#160f21',
++ '--bg-tertiary': '#20162e',
++ '--bg-card': '#191125',
++ '--bg-hover': '#291d3a',
++ '--bg-input': '#110b1a',
++ '--text-primary': '#f4eef8',
++ '--text-secondary': '#bba9cc',
++ '--text-muted': '#9784ab',
++ '--accent': '#ff7a1a',
++ '--accent-light': '#ffa14f',
++ '--accent-dark': '#d65d00',
++ '--border': '#2d2040',
++ '--border-light': '#3e2c56',
++ '--live-red': '#ef4444',
++ '--success': '#7ee26b',
++ '--warning': '#fbbf24',
++ '--danger': '#f43f5e',
++ '--info': '#b08cff',
++ '--shadow': '0 2px 12px rgba(5,0,12,0.55)',
++ '--shadow-lg': '0 8px 32px rgba(5,0,12,0.7)',
++ },
++ },
++ {
++ name: 'Frost',
++ slug: 'frost',
++ mode: 'dark',
++ description: 'Winter: frosted teal over a cold midnight sky. On by itself through December.',
++ tags: ['seasonal', 'winter', 'teal', 'cool'],
++ season: { id: 'winter', name: 'Winter', from: '12-01', to: '12-31' },
++ variables: {
++ '--bg-primary': '#08111b',
++ '--bg-secondary': '#0d1926',
++ '--bg-tertiary': '#132334',
++ '--bg-card': '#0f1c2b',
++ '--bg-hover': '#18293d',
++ '--bg-input': '#0a1520',
++ '--text-primary': '#eaf4fb',
++ '--text-secondary': '#a3b9cc',
++ '--text-muted': '#7f97ad',
++ '--accent': '#2dd4bf',
++ '--accent-light': '#7ee8da',
++ '--accent-dark': '#0f9e8c',
++ '--border': '#1b2d41',
++ '--border-light': '#294057',
++ '--live-red': '#ef4444',
++ '--success': '#4ade80',
++ '--warning': '#fbbf24',
++ '--danger': '#f87171',
++ '--info': '#93c5fd',
++ '--shadow': '0 2px 12px rgba(0,6,14,0.5)',
++ '--shadow-lg': '0 8px 32px rgba(0,6,14,0.65)',
++ },
++ },
];
// ── Derived tokens ──────────────────────────────────────────────────────────────
@@@ -906,7 +972,7 @@ const RAW_BUILTIN_THEMES = [
// a filled button's background and its label at 4.5:1 or better (WCAG AA for
// normal text): the accent, darkened under white or lightened under dark ink just
// enough (the Sign In button; white on #3b82f6 was 3.67:1)
--// A theme may set any of them explicitly; otherwise they are computed here so all 35 themes,
++// A theme may set any of them explicitly; otherwise they are computed here so all 37 themes,
// and community themes that only define the base palette, stay consistent on every site.
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex || '').trim());
@@@ -999,10 +1065,20 @@ const BUILTIN_THEMES = RAW_BUILTIN_THEMES.map((theme) => ({
}));
const DEFAULT_THEME = BUILTIN_THEMES.find((theme) => theme.id === 'vibe') || BUILTIN_THEMES[0];
++// The seasons, in calendar order: { id, name, theme, from: 'MM-DD', to: 'MM-DD' } (theme-loader.js carries a copy).
++const SEASONS = BUILTIN_THEMES.filter((theme) => theme.season).map((theme) => ({ id: theme.season.id, name: theme.season.name, theme: theme.id, from: theme.season.from, to: theme.season.to }))
++ .sort((a, b) => a.from.localeCompare(b.from));
++/** The season `date` falls in (the visitor's local calendar), or null. A season may run over New Year (from > to). */
++function seasonOn(date = new Date()) {
++ const md = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
++ return SEASONS.find((s) => (s.from <= s.to ? md >= s.from && md <= s.to : md >= s.from || md <= s.to)) || null;
++}
const DEFAULT_VARS = Object.freeze({ ...(DEFAULT_THEME?.variables || {}) });
module.exports = {
BUILTIN_THEMES,
++ SEASONS,
++ seasonOn,
DEFAULT_THEME,
DEFAULT_VARS,
deriveTokens,
ddiff --git a/navbar.js b/navbar.js
iindex 546799d..83684e4 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -61,6 +61,10 @@
.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; }
@@@ -527,6 +531,30 @@
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"/>',
++ };
++ 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());
++ }
++
function brandHTML(brand) {
const to = brandLinks(brand);
const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;
@@@ -542,6 +570,7 @@
<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>'}`;
}
@@@ -777,7 +806,7 @@
function bindDisplayRows(dropdown) {
const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');
if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }
-- const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };
++ const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' }, seasonal: { on: 'On', off: 'Off' } };
const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; }); };
rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));
root.addEventListener('ov:display', paint, onDocument()); paint();
@@@ -821,7 +850,7 @@
const d = L.display.get();
const seg = (key, val, label, title) => `<button type="button" data-k="${key}" data-v="${val}" aria-pressed="${String(d[key]) === val}" title="${title}">${label}</button>`;
box.innerHTML = `<span class="ovl-dl">Display</span><span class="ovl-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>
-- <span class="ovl-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span><a href="https://openvibe.network/themes">Themes</a>`;
++ <span class="ovl-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span>${seasonState() ? `<span class="ovl-seg" role="group" aria-label="Seasonal themes">${seg('seasonal', 'on', seasonState().s.name, 'Seasonal themes on')}${seg('seasonal', 'off', 'Off', 'Seasonal themes off')}</span>` : ''}<a href="https://openvibe.network/themes">Themes</a>`;
box.hidden = false;
};
box.addEventListener('click', (e) => { const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint(); });
@@@ -1404,6 +1433,7 @@
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);
@@@ -1459,6 +1489,7 @@
<div class="ud-label">Display</div>
<button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button>
<button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>
++ <button type="button" data-ov-display="seasonal"><span class="icon">${navIcon('fa-palette')}</span> Seasonal themes <b class="ud-val"></b></button>
<a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>
<div class="sep"></div>
<div class="ud-label">Across OpenVibe</div>
ddiff --git a/package-lock.json b/package-lock.json
iindex 7b80d72..5904c63 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.10.0",
++ "version": "4.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.10.0",
++ "version": "4.11.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 6005d89..275dbbc 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.10.0",
++ "version": "4.11.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/scripts/build-theme-loader.js b/scripts/build-theme-loader.js
iindex 84034cf..f59934d 100644
---- a/scripts/build-theme-loader.js
++++ b/scripts/build-theme-loader.js
@@@ -9,13 +9,15 @@
* node scripts/build-theme-loader.js # rewrite
* node scripts/build-theme-loader.js --check # exit 1 if stale
*
++ * The seasons (builtin-themes.js SEASONS) come along as SEASONS: which theme is the default when.
++ *
* Every entry carries the FULL variable set (the theme's own values over the default theme's), so
* switching themes replaces every token — a theme that does not name --success no longer keeps the
* previous theme's --success on <html>.
*/
const fs = require('fs');
const path = require('path');
--const { BUILTIN_THEMES, DEFAULT_VARS } = require('../builtin-themes');
++const { BUILTIN_THEMES, DEFAULT_VARS, SEASONS } = require('../builtin-themes');
const LOADER = path.join(__dirname, '..', 'theme-loader.js');
const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';
@@@ -27,7 +29,8 @@ function render() {
const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');
return ` '${t.id}': { ${body} },`;
});
-- return `${BEGIN}\n const THEMES = {\n${lines.join('\n')}\n };\n${END}`;
++ const seasons = SEASONS.map((s) => ` { id: '${s.id}', name: '${s.name.replace(/'/g, "\\'")}', theme: '${s.theme}', from: '${s.from}', to: '${s.to}' },`);
++ return `${BEGIN}\n const THEMES = {\n${lines.join('\n')}\n };\n const SEASONS = [\n${seasons.join('\n')}\n ];\n${END}`;
}
function currentBlock(src) {
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex f1abe4b..6082600 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
--const NAVBAR_BROTLI_BUDGET = 32 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0)
++const NAVBAR_BROTLI_BUDGET = 33 * 1024; // measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex 4b71fa2..e50fa50 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -207,4 +207,28 @@ function load(hostname, opts = {}) {
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 ────
++{
++ 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 } };
++ navbar.init({ service: 'live' });
++ assert.ok(!created.find((e) => e.tag === 'nav').innerHTML.includes('ovnav-season'), 'no season, no badge');
++ delete global.OpenVibeThemeLoader;
++}
console.log('navbar brand: all checks passed');
ddiff --git a/test/seasonal-themes.test.js b/test/seasonal-themes.test.js
nnew file mode 100644
iindex 0000000..35aaa40
---- /dev/null
++++ b/test/seasonal-themes.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++// Seasonal themes (builtin-themes.js `season`, theme-loader.js Seasons): during a season its theme stands in for the
++// default theme for anyone on the default who has not turned seasonal themes off; a theme someone picked stays theirs;
++// the `seasonal` display preference switches it, at once and in every tab; picking the default theme during a season
++// turns seasonal themes off. theme-loader.js runs here in a small fake browser on fixed dates.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { BUILTIN_THEMES, SEASONS, seasonOn } = require('../builtin-themes');
++
++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 = '') {
++ 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 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),
++ attrs, props,
++ };
++ 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() {} },
++ 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) }); },
++ 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); },
++ };
++ 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)) };
++}
++const accent = (p) => p.html.props.get('--accent');
++
++// The catalog: two seasons, each a complete theme, found by date (both ends included).
++assert.deepStrictEqual(SEASONS.map((s) => [s.id, s.theme]), [['halloween', 'halloween'], ['winter', 'frost']]);
++assert.strictEqual(seasonOn(new Date(2026, 9, 1)).id, 'halloween');
++assert.strictEqual(seasonOn(new Date(2026, 9, 31)).id, 'halloween');
++assert.strictEqual(seasonOn(new Date(2026, 10, 1)), null);
++assert.strictEqual(seasonOn(new Date(2026, 11, 24)).id, 'winter');
++assert.ok(by.halloween.tags.includes('seasonal') && by.frost.tags.includes('seasonal'));
++
++// 1. October, nothing chosen: Pumpkin Night shows; the person's theme is still the default.
++let p = load('2026-10-24');
++assert.strictEqual(accent(p), by.halloween.variables['--accent']);
++assert.strictEqual(p.html.getAttribute('data-ov-season'), 'halloween');
++assert.strictEqual(p.html.getAttribute('data-theme'), 'halloween');
++assert.strictEqual(p.L.getCurrent(), 'vibe', 'the saved choice stays the default');
++assert.strictEqual(p.L.seasons.showing().id, 'halloween');
++assert.strictEqual(p.L.display.get().seasonal, 'on', 'seasonal themes are on unless turned off');
++
++// 2. A theme someone picked stays theirs; a custom take on the default too.
++const picked = new Map([['ov_theme', JSON.stringify({ id: 'nord', variables: by.nord.variables })]]);
++p = load('2026-10-24', picked);
++assert.strictEqual(accent(p), by.nord.variables['--accent']);
++assert.strictEqual(p.html.getAttribute('data-ov-season'), null);
++p = load('2026-10-24', new Map([['ov_theme', JSON.stringify({ id: 'vibe', variables: { ...by.vibe.variables, '--accent': '#ff00aa' }, custom: true })]]));
++assert.strictEqual(accent(p), '#ff00aa', 'a customised default is a choice');
++
++// 3. Turning seasonal themes off repaints at once to the normal default, saves it to the account (signed in), and on
++// again repaints.
++p = load('2026-10-24', new Map(), 'ov_sso_hint=account');
++p.L.display.set({ seasonal: 'off' });
++assert.strictEqual(accent(p), by.vibe.variables['--accent']);
++assert.strictEqual(p.html.getAttribute('data-ov-season'), null);
++assert.ok(p.events.some((e) => e.type === 'ov:season' && e.detail === null), 'ov:season says it went');
++assert.ok(p.puts.some((x) => /\/api\/themes\/me\/display$/.test(x.url) && x.body.seasonal === 'off'), 'the choice follows the account');
++p.L.display.set({ seasonal: 'on' });
++assert.strictEqual(accent(p), by.halloween.variables['--accent']);
++// Remembered on the next page load, on any site (here: the same storage).
++const off = new Map(); load('2026-10-24', off).L.display.set({ seasonal: 'off' });
++assert.strictEqual(accent(load('2026-10-24', off)), by.vibe.variables['--accent']);
++
++// 4. Another tab turning it off repaints this one.
++const shared = new Map();
++p = load('2026-10-24', shared);
++shared.set('ov_display', JSON.stringify({ motion: 'auto', text: '100', seasonal: 'off' }));
++p.fire('storage', { key: 'ov_display' });
++assert.strictEqual(accent(p), by.vibe.variables['--accent']);
++
++// 5. Picking the default theme during a season means "my normal theme": seasonal themes go off.
++p = load('2026-10-24');
++p.L.save('vibe', null, { sync: false });
++assert.strictEqual(p.L.display.get().seasonal, 'off');
++assert.strictEqual(accent(p), by.vibe.variables['--accent']);
++// Picking the season's own theme keeps it all year.
++p = load('2026-11-20');
++p.L.save('halloween', null, { sync: false });
++assert.strictEqual(accent(load('2026-11-21', p.store)), by.halloween.variables['--accent']);
++
++// 6. Out of season the default is the default; December brings Frost.
++assert.strictEqual(accent(load('2026-11-05')), by.vibe.variables['--accent']);
++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');
ddiff --git a/theme-loader.js b/theme-loader.js
iindex fba9901..aa25516 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -56,9 +56,49 @@
'catppuccin-mocha': { '--bg-primary':'#1e1e2e','--bg-secondary':'#181825','--bg-tertiary':'#313244','--bg-card':'#1e1e2e','--bg-hover':'#313244','--bg-input':'#1a1a2c','--text-primary':'#cdd6f4','--text-secondary':'#bac2de','--text-muted':'#989caf','--accent':'#cba6f7','--accent-light':'#dcc0ff','--accent-dark':'#b07de0','--border':'#313244','--border-light':'#45475a','--live-red':'#f38ba8','--success':'#a6e3a1','--warning':'#fab387','--danger':'#f38ba8','--info':'#89b4fa','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#a6e3a1','--warning-text':'#fab387','--danger-text':'#f38ba8','--info-text':'#89b4fa','--live-red-text':'#f38ba8','--accent-strong':'#cba6f7','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'203,166,247','--accent-glow':'rgba(203,166,247,0.35)','--color-scheme':'dark' },
'catppuccin-latte': { '--bg-primary':'#eff1f5','--bg-secondary':'#e6e9ef','--bg-tertiary':'#dce0e8','--bg-card':'#eff1f5','--bg-hover':'#dce0e8','--bg-input':'#e8ebf0','--text-primary':'#4c4f69','--text-secondary':'#5c5f77','--text-muted':'#5f6176','--accent':'#7d34dc','--accent-light':'#7a46c2','--accent-dark':'#7028d4','--border':'#ccd0da','--border-light':'#bcc0cc','--live-red':'#d20f39','--success':'#40a02b','--warning':'#fe640b','--danger':'#d20f39','--info':'#1e66f5','--shadow':'0 2px 12px rgba(0,0,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.10)','--success-text':'#2d701e','--warning-text':'#a84207','--danger-text':'#c50e36','--info-text':'#1a5ad8','--live-red-text':'#c50e36','--accent-strong':'#7d34dc','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'125,52,220','--accent-glow':'rgba(125,52,220,0.35)','--color-scheme':'light' },
'high-contrast': { '--bg-primary':'#000000','--bg-secondary':'#0a0a0a','--bg-tertiary':'#1a1a1a','--bg-card':'#0f0f0f','--bg-hover':'#222222','--bg-input':'#0a0a0a','--text-primary':'#ffffff','--text-secondary':'#f0f0f0','--text-muted':'#aaaaaa','--accent':'#ffd700','--accent-light':'#ffed4a','--accent-dark':'#ccac00','--border':'#444444','--border-light':'#666666','--live-red':'#ff0000','--success':'#00ff00','--warning':'#ffff00','--danger':'#ff0000','--info':'#00ffff','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#00ff00','--warning-text':'#ffff00','--danger-text':'#ff3d3d','--info-text':'#00ffff','--live-red-text':'#ff3d3d','--accent-strong':'#ffd700','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,215,0','--accent-glow':'rgba(255,215,0,0.35)','--color-scheme':'dark' },
++ 'halloween': { '--bg-primary':'#0e0a14','--bg-secondary':'#160f21','--bg-tertiary':'#20162e','--bg-card':'#191125','--bg-hover':'#291d3a','--bg-input':'#110b1a','--text-primary':'#f4eef8','--text-secondary':'#bba9cc','--text-muted':'#9784ab','--accent':'#ff7a1a','--accent-light':'#ffa14f','--accent-dark':'#d65d00','--border':'#2d2040','--border-light':'#3e2c56','--live-red':'#ef4444','--success':'#7ee26b','--warning':'#fbbf24','--danger':'#f43f5e','--info':'#b08cff','--shadow':'0 2px 12px rgba(5,0,12,0.55)','--shadow-lg':'0 8px 32px rgba(5,0,12,0.7)','--success-text':'#7ee26b','--warning-text':'#fbbf24','--danger-text':'#f54b68','--info-text':'#b08cff','--live-red-text':'#f05353','--accent-strong':'#ff7a1a','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,122,26','--accent-glow':'rgba(255,122,26,0.35)','--color-scheme':'dark' },
++ 'frost': { '--bg-primary':'#08111b','--bg-secondary':'#0d1926','--bg-tertiary':'#132334','--bg-card':'#0f1c2b','--bg-hover':'#18293d','--bg-input':'#0a1520','--text-primary':'#eaf4fb','--text-secondary':'#a3b9cc','--text-muted':'#7f97ad','--accent':'#2dd4bf','--accent-light':'#7ee8da','--accent-dark':'#0f9e8c','--border':'#1b2d41','--border-light':'#294057','--live-red':'#ef4444','--success':'#4ade80','--warning':'#fbbf24','--danger':'#f87171','--info':'#93c5fd','--shadow':'0 2px 12px rgba(0,6,14,0.5)','--shadow-lg':'0 8px 32px rgba(0,6,14,0.65)','--success-text':'#4ade80','--warning-text':'#fbbf24','--danger-text':'#f87171','--info-text':'#93c5fd','--live-red-text':'#f15e5e','--accent-strong':'#2dd4bf','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'45,212,191','--accent-glow':'rgba(45,212,191,0.35)','--color-scheme':'dark' },
};
++ const SEASONS = [
++ { id: 'halloween', name: 'Halloween', theme: 'halloween', from: '10-01', to: '10-31' },
++ { id: 'winter', name: 'Winter', theme: 'frost', from: '12-01', to: '12-31' },
++ ];
// THEMES:END
++ // ── Seasons ──────────────────────────────────────────────
++ // During a season (SEASONS above, the visitor's own calendar) its theme stands in for the default theme for anyone
++ // on the default who has not turned seasonal themes off: the display preference `seasonal` (on|off), saved to the
++ // account and followed on every site like text size. A theme someone picked stays theirs. While one shows, <html>
++ // carries data-ov-season="<id>" and window gets an 'ov:season' event ({ id, name, theme } or null).
++ const DEFAULT_ID = 'vibe';
++ function seasonNow(date) {
++ const d = date || new Date();
++ const md = String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
++ for (let i = 0; i < SEASONS.length; i++) {
++ const s = SEASONS[i];
++ if (s.from <= s.to ? (md >= s.from && md <= s.to) : (md >= s.from || md <= s.to)) return s;
++ }
++ return null;
++ }
++ let _season = null;
++ let _shown = { id: DEFAULT_ID, vars: null, custom: false };
++ /** Paint the theme `themeId` (its variables `vars`), or the season's theme in its place. → the id painted. */
++ function show(themeId, vars, custom) {
++ _shown = { id: themeId, vars: vars, custom: !!custom };
++ const s = themeId === DEFAULT_ID && !custom && readDisplay().seasonal !== 'off' ? seasonNow() : null;
++ const seasonal = s && THEMES[s.theme] ? s : null;
++ applyVars(seasonal ? THEMES[seasonal.theme] : vars);
++ const changed = (_season && _season.id) !== (seasonal && seasonal.id);
++ _season = seasonal;
++ try {
++ const el = document.documentElement;
++ if (seasonal) el.setAttribute('data-ov-season', seasonal.id); else el.removeAttribute('data-ov-season');
++ el.setAttribute('data-theme', seasonal ? seasonal.theme : themeId);
++ } catch { /* no DOM */ }
++ if (changed) { try { window.dispatchEvent(new CustomEvent('ov:season', { detail: seasonal })); } catch { /* */ } }
++ return seasonal ? seasonal.theme : themeId;
++ }
++
function getCookie(name) {
const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
@@@ -177,29 +217,25 @@
if (raw) {
const saved = JSON.parse(raw);
if (saved && saved.variables && typeof saved.variables === 'object') {
-- applyVars(saved.variables);
-- const cachedId = saved.id || saved.slug || 'vibe';
-- try { document.documentElement.setAttribute('data-theme', cachedId); } catch { /* no DOM */ }
-- return cachedId;
++ return show(saved.id || saved.slug || DEFAULT_ID, saved.variables, saved.custom);
}
if (saved && (saved.id || saved.slug)) {
const id = saved.id || saved.slug;
-- if (applyById(id)) return id;
++ if (THEMES[id]) return show(id, THEMES[id]);
}
}
} catch { /* corrupt localStorage */ }
// 2. Try cross-domain cookie
const cookieId = getCookie(COOKIE_NAME);
-- if (cookieId && applyById(cookieId)) {
++ if (cookieId && THEMES[cookieId]) {
// Cache the full variable set so the next load paints from localStorage instantly.
cacheLocally(cookieId, THEMES[cookieId]);
-- return cookieId;
++ return show(cookieId, THEMES[cookieId]);
}
-- // 3. Default — vibe (already the CSS fallback values, but apply explicitly)
-- applyById('vibe');
-- return 'vibe';
++ // 3. Default — vibe (already the CSS fallback values, but apply explicitly), or the season's theme
++ return show(DEFAULT_ID, THEMES[DEFAULT_ID]);
}
function authToken() {
@@@ -213,14 +249,13 @@
* including for community/custom themes that aren't in the built-in map — so the next
* page load can paint the right colours synchronously with no network call and no flash.
*/
-- function cacheLocally(themeId, vars) {
++ function cacheLocally(themeId, vars, custom) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
-- id: themeId, slug: themeId, variables: vars, at: Date.now(),
++ id: themeId, slug: themeId, variables: vars, at: Date.now(), ...(custom ? { custom: true } : {}),
}));
} catch { /* quota */ }
setCookie(COOKIE_NAME, themeId, 365);
-- try { document.documentElement.setAttribute('data-theme', themeId); } catch { /* no DOM */ }
}
/**
@@@ -233,8 +268,10 @@
const base = variables || THEMES[themeId] || {};
const vars = custom ? Object.assign({}, base, custom) : base;
if (!vars || !Object.keys(vars).length) return;
-- applyVars(vars); // saving a theme shows it: every token, the canvas and the browser UI follow at once
-- cacheLocally(themeId, vars);
++ // Picking the default theme while a season's stands in for it means "my normal theme": seasonal themes go off.
++ if (themeId === DEFAULT_ID && !custom && _season && !(opts && opts.keepSeasonal)) setDisplay({ seasonal: 'off' }, { noRepaint: true });
++ cacheLocally(themeId, vars, !!custom);
++ show(themeId, vars, !!custom); // saving a theme shows it: every token, the canvas and the browser UI follow at once
if (opts && opts.sync === false) return;
if (typeof fetch === 'undefined') return;
const token = authToken();
@@@ -266,9 +303,10 @@
// OpenVibeThemeLoader.display.set({ motion, text }) applies here, in other tabs, and saves to the account
// Applied as <html data-ov-motion> and --ov-text-scale before first paint; the shared stylesheet
// below makes every shared component honour them, and sites can key their own CSS off the same hooks.
-- const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'] };
++ // seasonal: 'on'|'off' the season's theme in place of the default theme (see Seasons above)
++ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'] };
function readDisplay() {
-- const out = { motion: 'auto', text: '100' };
++ const out = { motion: 'auto', text: '100', seasonal: 'on' };
try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }
return out;
}
@@@ -286,9 +324,11 @@
}
function setDisplay(patch, opts) {
const d = readDisplay();
++ const wasSeasonal = d.seasonal;
for (const k in DISPLAY_OPTS) if (patch && DISPLAY_OPTS[k].indexOf(String(patch[k])) >= 0) d[k] = String(patch[k]);
try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(d)); } catch (e) { /* */ }
applyDisplay(d);
++ 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 () {});
@@@ -301,7 +341,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()); });
++ 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); } });
// 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'
@@@ -327,7 +367,7 @@
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 }) !== 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)) 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'
@@@ -336,8 +376,8 @@
const settle = function (base) {
const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
if (!vars || !Object.keys(vars).length) return;
-- applyVars(vars);
-- cacheLocally(serverId, vars); // cacheLocally, not save() — no write-back loop
++ cacheLocally(serverId, vars, !!custom); // cacheLocally, not save() — no write-back loop
++ show(serverId, vars, !!custom);
};
if (builtin) return settle(builtin);
fetchThemeVars(serverId).then(settle);
@@@ -352,6 +392,8 @@
display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS },
apply: applyById,
applyVars: applyVars,
++ // Seasons: SEASONS, now() → the season today (or null), showing() → the season whose theme is on screen (or null)
++ seasons: { SEASONS: SEASONS, now: seasonNow, showing: function () { return _season; } },
onColor: onColor,
save: save,
getCurrent: getCurrent,