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)

OP OpenVibers committed 20 days ago · c388b7c5ac · 5 files, +56 −3

Changes

ddiff --git a/public/css/my.css b/public/css/my.css
iindex 4dcd01d9..1c04d34e 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -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}
ddiff --git a/public/js/my/themes.js b/public/js/my/themes.js
iindex abc5458c..efbb031d 100644
---- a/public/js/my/themes.js
++++ b/public/js/my/themes.js
@@@ -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();
ddiff --git a/public/my.html b/public/my.html
iindex b72621cd..ed1f7d4e 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -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>
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex cf9819a6..90b08732 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -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 = {};
ddiff --git a/test/themes-review.test.js b/test/themes-review.test.js
iindex 8f25fb98..53fa74c4 100644
---- a/test/themes-review.test.js
++++ b/test/themes-review.test.js
@@@ -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.js
const assert = require('assert');
const crypto = require('crypto');
@@@ -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.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');