openvibers/openvibe.network Commits
The seasonal-themes opt-out follows the account (display preference seasonal: on|off), and /themes shows the season with its switch (#317)
Changes
@@@ -164,6 +164,11 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat.theme-current .label{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em}.theme-current .name{font-size:13px;font-weight:700;margin-top:2px}.theme-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}++/* Seasonal themes (themes.js renderSeason): the season's switch above the grid. */++.theme-season{display:flex;align-items:center;justify-content:space-between;gap:12px 16px;flex-wrap:wrap;margin:0 0 14px;padding:14px 16px;border-radius:14px;border:1px solid color-mix(in srgb,var(--accent) 40%,var(--border));background:color-mix(in srgb,var(--accent) 9%,var(--bg-card))}++.theme-season[hidden]{display:none}++.theme-season strong{display:block;font-size:1rem}++.theme-season p{margin:4px 0 0;color:var(--text-secondary);font-size:.9rem;max-width:60ch}.theme-custom-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}.theme-custom-row{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-secondary);background:var(--bg-input);border:1px solid var(--border);border-radius:10px;padding:8px 10px;cursor:pointer}.theme-custom-row input[type=color]{width:34px;height:34px;border:none;border-radius:8px;background:none;cursor:pointer;padding:0}@@@ -45,7 +45,42 @@ function updateThemeSummary(theme) {});}++// ── Seasonal themes (openvibe-shared theme-loader Seasons) ──────────────────────++// During a season its theme (Pumpkin Night in October, Frost in December) stands in for the default theme unless the++// person turns seasonal themes off; the switch is the `seasonal` display preference, saved to the account.++const SEASON_MONTHS = { halloween: 'October', winter: 'December' };++function seasonLoader() { return (typeof OpenVibeThemeLoader !== 'undefined' && OpenVibeThemeLoader.seasons && OpenVibeThemeLoader.display) ? OpenVibeThemeLoader : null; }++function renderSeason() {++ const box = document.getElementById('theme-season');++ const L = seasonLoader();++ const now = L && L.seasons.now();++ if (!box) return;++ if (!now) { box.hidden = true; return; }++ const on = L.display.get().seasonal !== 'off';++ const showing = !!L.seasons.showing();++ const theme = availableThemes.find((t) => t.id === now.theme);++ box.hidden = false;++ box.innerHTML = '';++ const text = document.createElement('div');++ const h = document.createElement('strong');++ h.textContent = `${now.name}: ${theme ? theme.name : 'a seasonal theme'} is ${showing ? 'on' : on ? 'on for the default theme' : 'off'}`;++ const p = document.createElement('p');++ p.textContent = showing++ ? `Through ${SEASON_MONTHS[now.id] || 'the season'}, every OpenVibe site wears it in place of the default theme. Turn it off for your normal theme.`++ : on ? 'It shows when your theme is the default (Vibe). Your own theme stays as you picked it.'++ : 'You see your normal theme. Turn seasonal themes back on to see the season\'s theme on the default.';++ text.append(h, p);++ const btn = document.createElement('button');++ btn.type = 'button';++ btn.className = `btn ${on ? 'btn-outline' : 'btn-primary'}`;++ btn.textContent = on ? 'Turn seasonal themes off' : 'Turn seasonal themes on';++ btn.addEventListener('click', () => { L.display.set({ seasonal: on ? 'off' : 'on' }); renderSeason(); });++ box.append(text, btn);++}++window.addEventListener('ov:season', () => renderSeason());++function renderThemes() {++ renderSeason();const grid = document.getElementById('theme-grid');if (!grid) return;if (!availableThemes.length) {@@@ -71,6 +106,7 @@ function renderThemes() {<div class="theme-card-footer"><div class="theme-badges">${theme.is_builtin ? '<span class="theme-badge">Built-in</span>' : '<span class="theme-badge">Community</span>'}++ ${(theme.tags || []).includes('seasonal') ? '<span class="theme-badge">Seasonal</span>' : ''}</div><button class="btn ${isActive ? 'btn-outline' : 'btn-primary'}" onclick="setTheme('${theme.id}')">${isActive ? 'Active' : 'Use theme'}</button></div>@@@ -93,7 +129,8 @@ async function loadThemes() {activeCustomVars = (activeData.custom_variables && typeof activeData.custom_variables === 'object') ? activeData.custom_variables : {};const activeTheme = availableThemes.find((theme) => String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId));activeThemeVars = (activeTheme && activeTheme.variables) || {};-- if (activeTheme) applyThemeToPage(activeTheme);++ const seasonShowing = seasonLoader() && seasonLoader().seasons.showing() && String(activeThemeId) === 'vibe';++ if (activeTheme && !seasonShowing) applyThemeToPage(activeTheme);if (Object.keys(activeCustomVars).length) applyThemeToPage({ variables: Object.assign({}, activeThemeVars, activeCustomVars) });renderThemes();renderThemeCustomizer();@@@ -137,6 +137,7 @@</div></div></div>++ <div id="theme-season" class="theme-season" hidden></div><div id="theme-grid" class="theme-grid"><div class="theme-empty">Loading themes…</div></div>@@@ -108,7 +108,9 @@ router.get('/me/active', (req, res, next) => {});// ── Display preferences (motion, text size) — follow the account to every site ──--const DISPLAY = { motion: ['auto', 'reduced'], text: ['100', '112', '125'] };++// seasonal: the season's theme (openvibe-shared theme-loader Seasons: Pumpkin Night in October, Frost in December) in place++// of the default theme, unless 'off' (the owner, 2026-10-24: "users can opt out … and just want their normal default").++const DISPLAY = { motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'] };function cleanDisplay(d) {if (!d || typeof d !== 'object') return null;const out = {};@@@ -2,7 +2,8 @@// Community themes (roadmap WS-E task 2): a submission is pending and private to its author until an// admin approves it; the catalog shows approved themes only; theme values are allow-listed (only the// shared token names, only colours, numbers, lengths and shadows); a theme exports as an--// openvibe-theme@1 file and imports as a new submission; custom overrides are cleaned the same way.++// openvibe-theme@1 file and imports as a new submission; custom overrides are cleaned the same way; display preferences++// (text size, motion, seasonal themes) are allow-listed too.// node test/themes-review.test.jsconst assert = require('assert');const crypto = require('crypto');@@@ -101,6 +102,13 @@ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHERassert.deepStrictEqual([r.status, r.body.theme.slug], [200, 'ember'], 'followed, it is the ember theme');assert.strictEqual((await call('GET', '/api/themes/no-such-theme')).status, 404, 'an unknown name is still a 404');++ // ── Display preferences follow the account: text size, motion and seasonal themes (on|off), nothing else ──++ r = await call('PUT', '/api/themes/me/display', { as: 2, body: { text: '112', motion: 'reduced', seasonal: 'off', colour: 'red' } });++ assert.deepStrictEqual(r.body.display, { motion: 'reduced', text: '112', seasonal: 'off' }, 'seasonal is kept, an unknown key is not');++ assert.deepStrictEqual((await call('GET', '/api/themes/me/active', { as: 2 })).body.display, { motion: 'reduced', text: '112', seasonal: 'off' });++ r = await call('PUT', '/api/themes/me/display', { as: 2, body: { seasonal: 'maybe' } });++ assert.ok(!('seasonal' in (r.body.display || {})), 'only on or off');++server.close();fs.rmSync(dir, { recursive: true, force: true });console.log('themes review: all checks passed');
@@ -164,6 +164,11 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -164,6 +164,11 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
.theme-current .label{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em}
.theme-current .label{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em}
.theme-current .name{font-size:13px;font-weight:700;margin-top:2px}
.theme-current .name{font-size:13px;font-weight:700;margin-top:2px}
.theme-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
.theme-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
+/* Seasonal themes (themes.js renderSeason): the season's switch above the grid. */
+.theme-season{display:flex;align-items:center;justify-content:space-between;gap:12px 16px;flex-wrap:wrap;margin:0 0 14px;padding:14px 16px;border-radius:14px;border:1px solid color-mix(in srgb,var(--accent) 40%,var(--border));background:color-mix(in srgb,var(--accent) 9%,var(--bg-card))}
+.theme-season[hidden]{display:none}
+.theme-season strong{display:block;font-size:1rem}
+.theme-season p{margin:4px 0 0;color:var(--text-secondary);font-size:.9rem;max-width:60ch}
.theme-custom-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.theme-custom-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.theme-custom-row{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-secondary);background:var(--bg-input);border:1px solid var(--border);border-radius:10px;padding:8px 10px;cursor:pointer}
.theme-custom-row{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-secondary);background:var(--bg-input);border:1px solid var(--border);border-radius:10px;padding:8px 10px;cursor:pointer}
.theme-custom-row input[type=color]{width:34px;height:34px;border:none;border-radius:8px;background:none;cursor:pointer;padding:0}
.theme-custom-row input[type=color]{width:34px;height:34px;border:none;border-radius:8px;background:none;cursor:pointer;padding:0}
@@ -45,7 +45,42 @@ function updateThemeSummary(theme) {
@@ -45,7 +45,42 @@ function updateThemeSummary(theme) {
});
});
}
}
+// ── Seasonal themes (openvibe-shared theme-loader Seasons) ──────────────────────
+// During a season its theme (Pumpkin Night in October, Frost in December) stands in for the default theme unless the
+// person turns seasonal themes off; the switch is the `seasonal` display preference, saved to the account.
+const SEASON_MONTHS = { halloween: 'October', winter: 'December' };
+function seasonLoader() { return (typeof OpenVibeThemeLoader !== 'undefined' && OpenVibeThemeLoader.seasons && OpenVibeThemeLoader.display) ? OpenVibeThemeLoader : null; }
+function renderSeason() {
+ const box = document.getElementById('theme-season');
+ const L = seasonLoader();
+ const now = L && L.seasons.now();
+ if (!box) return;
+ if (!now) { box.hidden = true; return; }
+ const on = L.display.get().seasonal !== 'off';
+ const showing = !!L.seasons.showing();
+ const theme = availableThemes.find((t) => t.id === now.theme);
+ box.hidden = false;
+ box.innerHTML = '';
+ const text = document.createElement('div');
+ const h = document.createElement('strong');
+ h.textContent = `${now.name}: ${theme ? theme.name : 'a seasonal theme'} is ${showing ? 'on' : on ? 'on for the default theme' : 'off'}`;
+ const p = document.createElement('p');
+ p.textContent = showing
+ ? `Through ${SEASON_MONTHS[now.id] || 'the season'}, every OpenVibe site wears it in place of the default theme. Turn it off for your normal theme.`
+ : on ? 'It shows when your theme is the default (Vibe). Your own theme stays as you picked it.'
+ : 'You see your normal theme. Turn seasonal themes back on to see the season\'s theme on the default.';
+ text.append(h, p);
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = `btn ${on ? 'btn-outline' : 'btn-primary'}`;
+ btn.textContent = on ? 'Turn seasonal themes off' : 'Turn seasonal themes on';
+ btn.addEventListener('click', () => { L.display.set({ seasonal: on ? 'off' : 'on' }); renderSeason(); });
+ box.append(text, btn);
+}
+window.addEventListener('ov:season', () => renderSeason());
+
function renderThemes() {
function renderThemes() {
+ renderSeason();
const grid = document.getElementById('theme-grid');
const grid = document.getElementById('theme-grid');
if (!grid) return;
if (!grid) return;
if (!availableThemes.length) {
if (!availableThemes.length) {
@@ -71,6 +106,7 @@ function renderThemes() {
@@ -71,6 +106,7 @@ function renderThemes() {
<div class="theme-card-footer">
<div class="theme-card-footer">
<div class="theme-badges">
<div class="theme-badges">
${theme.is_builtin ? '<span class="theme-badge">Built-in</span>' : '<span class="theme-badge">Community</span>'}
${theme.is_builtin ? '<span class="theme-badge">Built-in</span>' : '<span class="theme-badge">Community</span>'}
+ ${(theme.tags || []).includes('seasonal') ? '<span class="theme-badge">Seasonal</span>' : ''}
</div>
</div>
<button class="btn ${isActive ? 'btn-outline' : 'btn-primary'}" onclick="setTheme('${theme.id}')">${isActive ? 'Active' : 'Use theme'}</button>
<button class="btn ${isActive ? 'btn-outline' : 'btn-primary'}" onclick="setTheme('${theme.id}')">${isActive ? 'Active' : 'Use theme'}</button>
</div>
</div>
@@ -93,7 +129,8 @@ async function loadThemes() {
@@ -93,7 +129,8 @@ async function loadThemes() {
activeCustomVars = (activeData.custom_variables && typeof activeData.custom_variables === 'object') ? activeData.custom_variables : {};
activeCustomVars = (activeData.custom_variables && typeof activeData.custom_variables === 'object') ? activeData.custom_variables : {};
const activeTheme = availableThemes.find((theme) => String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId));
const activeTheme = availableThemes.find((theme) => String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId));
activeThemeVars = (activeTheme && activeTheme.variables) || {};
activeThemeVars = (activeTheme && activeTheme.variables) || {};
- if (activeTheme) applyThemeToPage(activeTheme);
+ const seasonShowing = seasonLoader() && seasonLoader().seasons.showing() && String(activeThemeId) === 'vibe';
+ if (activeTheme && !seasonShowing) applyThemeToPage(activeTheme);
if (Object.keys(activeCustomVars).length) applyThemeToPage({ variables: Object.assign({}, activeThemeVars, activeCustomVars) });
if (Object.keys(activeCustomVars).length) applyThemeToPage({ variables: Object.assign({}, activeThemeVars, activeCustomVars) });
renderThemes();
renderThemes();
renderThemeCustomizer();
renderThemeCustomizer();
@@ -137,6 +137,7 @@
@@ -137,6 +137,7 @@
</div>
</div>
</div>
</div>
</div>
</div>
+ <div id="theme-season" class="theme-season" hidden></div>
<div id="theme-grid" class="theme-grid">
<div id="theme-grid" class="theme-grid">
<div class="theme-empty">Loading themes…</div>
<div class="theme-empty">Loading themes…</div>
</div>
</div>
@@ -108,7 +108,9 @@ router.get('/me/active', (req, res, next) => {
@@ -108,7 +108,9 @@ router.get('/me/active', (req, res, next) => {
});
});
// ── Display preferences (motion, text size) — follow the account to every site ──
// ── Display preferences (motion, text size) — follow the account to every site ──
-const DISPLAY = { motion: ['auto', 'reduced'], text: ['100', '112', '125'] };
+// seasonal: the season's theme (openvibe-shared theme-loader Seasons: Pumpkin Night in October, Frost in December) in place
+// of the default theme, unless 'off' (the owner, 2026-10-24: "users can opt out … and just want their normal default").
+const DISPLAY = { motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'] };
function cleanDisplay(d) {
function cleanDisplay(d) {
if (!d || typeof d !== 'object') return null;
if (!d || typeof d !== 'object') return null;
const out = {};
const out = {};
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
// Community themes (roadmap WS-E task 2): a submission is pending and private to its author until an
// Community themes (roadmap WS-E task 2): a submission is pending and private to its author until an
// admin approves it; the catalog shows approved themes only; theme values are allow-listed (only the
// admin approves it; the catalog shows approved themes only; theme values are allow-listed (only the
// shared token names, only colours, numbers, lengths and shadows); a theme exports as an
// shared token names, only colours, numbers, lengths and shadows); a theme exports as an
-// openvibe-theme@1 file and imports as a new submission; custom overrides are cleaned the same way.
+// openvibe-theme@1 file and imports as a new submission; custom overrides are cleaned the same way; display preferences
+// (text size, motion, seasonal themes) are allow-listed too.
// node test/themes-review.test.js
// node test/themes-review.test.js
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -101,6 +102,13 @@ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHER
@@ -101,6 +102,13 @@ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHER
assert.deepStrictEqual([r.status, r.body.theme.slug], [200, 'ember'], 'followed, it is the ember theme');
assert.deepStrictEqual([r.status, r.body.theme.slug], [200, 'ember'], 'followed, it is the ember theme');
assert.strictEqual((await call('GET', '/api/themes/no-such-theme')).status, 404, 'an unknown name is still a 404');
assert.strictEqual((await call('GET', '/api/themes/no-such-theme')).status, 404, 'an unknown name is still a 404');
+ // ── Display preferences follow the account: text size, motion and seasonal themes (on|off), nothing else ──
+ r = await call('PUT', '/api/themes/me/display', { as: 2, body: { text: '112', motion: 'reduced', seasonal: 'off', colour: 'red' } });
+ assert.deepStrictEqual(r.body.display, { motion: 'reduced', text: '112', seasonal: 'off' }, 'seasonal is kept, an unknown key is not');
+ assert.deepStrictEqual((await call('GET', '/api/themes/me/active', { as: 2 })).body.display, { motion: 'reduced', text: '112', seasonal: 'off' });
+ r = await call('PUT', '/api/themes/me/display', { as: 2, body: { seasonal: 'maybe' } });
+ assert.ok(!('seasonal' in (r.body.display || {})), 'only on or off');
+
server.close();
server.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('themes review: all checks passed');
console.log('themes review: all checks passed');