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)
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.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@@@ -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,@@@ -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>@@@ -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"@@@ -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": {@@@ -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) {@@@ -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 ──────────────────────────{@@@ -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');nnew file mode 100644@@@ -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');@@@ -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 cookieconst 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 onceif (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,
@@ -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.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
## 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
- **items.css draws `name-fx-twofat`, "2fat Bass Drop"**, the one-of-a-kind name effect the owner asked for 2fat on
@@ -893,6 +893,72 @@ const RAW_BUILTIN_THEMES = [
@@ -893,6 +893,72 @@ const RAW_BUILTIN_THEMES = [
'--info': '#00ffff',
'--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 ──────────────────────────────────────────────────────────────
// ── Derived tokens ──────────────────────────────────────────────────────────────
@@ -906,7 +972,7 @@ const RAW_BUILTIN_THEMES = [
@@ -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
// 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
// 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)
// 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.
// and community themes that only define the base palette, stay consistent on every site.
function hexToRgb(hex) {
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex || '').trim());
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) => ({
@@ -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];
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 || {}) });
const DEFAULT_VARS = Object.freeze({ ...(DEFAULT_THEME?.variables || {}) });
module.exports = {
module.exports = {
BUILTIN_THEMES,
BUILTIN_THEMES,
+ SEASONS,
+ seasonOn,
DEFAULT_THEME,
DEFAULT_THEME,
DEFAULT_VARS,
DEFAULT_VARS,
deriveTokens,
deriveTokens,
@@ -61,6 +61,10 @@
@@ -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 { 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; }
@@ -527,6 +531,30 @@
@@ -527,6 +531,30 @@
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"/>',
+ };
+ 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) {
function brandHTML(brand) {
const to = brandLinks(brand);
const to = brandLinks(brand);
const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;
const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;
@@ -542,6 +570,7 @@
@@ -542,6 +570,7 @@
<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>'}`;
}
}
@@ -777,7 +806,7 @@
@@ -777,7 +806,7 @@
function bindDisplayRows(dropdown) {
function bindDisplayRows(dropdown) {
const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');
const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');
if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }
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]] || ''; }); };
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(); }));
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();
root.addEventListener('ov:display', paint, onDocument()); paint();
@@ -821,7 +850,7 @@
@@ -821,7 +850,7 @@
const d = L.display.get();
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>`;
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>
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.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(); });
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 @@
@@ -1404,6 +1433,7 @@
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);
@@ -1459,6 +1489,7 @@
@@ -1459,6 +1489,7 @@
<div class="ud-label">Display</div>
<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="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="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>
<a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>
<div class="sep"></div>
<div class="sep"></div>
<div class="ud-label">Across OpenVibe</div>
<div class="ud-label">Across OpenVibe</div>
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.10.0",
+ "version": "4.11.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.10.0",
+ "version": "4.11.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.10.0",
+ "version": "4.11.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": {
@@ -9,13 +9,15 @@
@@ -9,13 +9,15 @@
* node scripts/build-theme-loader.js # rewrite
* node scripts/build-theme-loader.js # rewrite
* node scripts/build-theme-loader.js --check # exit 1 if stale
* 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
* 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
* switching themes replaces every token — a theme that does not name --success no longer keeps the
* previous theme's --success on <html>.
* previous theme's --success on <html>.
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
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 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';
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() {
@@ -27,7 +29,8 @@ function render() {
const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');
const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');
return ` '${t.id}': { ${body} },`;
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) {
function currentBlock(src) {
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
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 ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -207,4 +207,28 @@ function load(hostname, opts = {}) {
@@ -207,4 +207,28 @@ function load(hostname, opts = {}) {
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 ────
+{
+ 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');
console.log('navbar brand: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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');
@@ -56,9 +56,49 @@
@@ -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-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' },
'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' },
'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
// 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) {
function getCookie(name) {
const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
return match ? decodeURIComponent(match[1]) : null;
@@ -177,29 +217,25 @@
@@ -177,29 +217,25 @@
if (raw) {
if (raw) {
const saved = JSON.parse(raw);
const saved = JSON.parse(raw);
if (saved && saved.variables && typeof saved.variables === 'object') {
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)) {
if (saved && (saved.id || saved.slug)) {
const id = 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 */ }
} catch { /* corrupt localStorage */ }
// 2. Try cross-domain cookie
// 2. Try cross-domain cookie
const cookieId = getCookie(COOKIE_NAME);
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.
// Cache the full variable set so the next load paints from localStorage instantly.
cacheLocally(cookieId, THEMES[cookieId]);
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() {
function authToken() {
@@ -213,14 +249,13 @@
@@ -213,14 +249,13 @@
* including for community/custom themes that aren't in the built-in map — so the next
* 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.
* 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 {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
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 */ }
} catch { /* quota */ }
setCookie(COOKIE_NAME, themeId, 365);
setCookie(COOKIE_NAME, themeId, 365);
- try { document.documentElement.setAttribute('data-theme', themeId); } catch { /* no DOM */ }
}
}
/**
/**
@@ -233,8 +268,10 @@
@@ -233,8 +268,10 @@
const base = variables || THEMES[themeId] || {};
const base = variables || THEMES[themeId] || {};
const vars = custom ? Object.assign({}, base, custom) : base;
const vars = custom ? Object.assign({}, base, custom) : base;
if (!vars || !Object.keys(vars).length) return;
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 (opts && opts.sync === false) return;
if (typeof fetch === 'undefined') return;
if (typeof fetch === 'undefined') return;
const token = authToken();
const token = authToken();
@@ -266,9 +303,10 @@
@@ -266,9 +303,10 @@
// OpenVibeThemeLoader.display.set({ motion, text }) applies here, in other tabs, and saves to the account
// 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
// 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.
// 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() {
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) { /* */ }
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;
return out;
}
}
@@ -286,9 +324,11 @@
@@ -286,9 +324,11 @@
}
}
function setDisplay(patch, opts) {
function setDisplay(patch, opts) {
const d = readDisplay();
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]);
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) { /* */ }
try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(d)); } catch (e) { /* */ }
applyDisplay(d);
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') {
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', body: JSON.stringify(d) }).catch(function () {});
@@ -301,7 +341,7 @@
@@ -301,7 +341,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()); });
+ 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
// 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'
@@ -327,7 +367,7 @@
@@ -327,7 +367,7 @@
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 }) !== 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;
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'
@@ -336,8 +376,8 @@
@@ -336,8 +376,8 @@
const settle = function (base) {
const settle = function (base) {
const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
if (!vars || !Object.keys(vars).length) return;
if (!vars || !Object.keys(vars).length) return;
- 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);
if (builtin) return settle(builtin);
fetchThemeVars(serverId).then(settle);
fetchThemeVars(serverId).then(settle);
@@ -352,6 +392,8 @@
@@ -352,6 +392,8 @@
display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS },
display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS },
apply: applyById,
apply: applyById,
applyVars: applyVars,
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,
onColor: onColor,
save: save,
save: save,
getCurrent: getCurrent,
getCurrent: getCurrent,