openvibers/openvibe.live Commits
Display settings in the user menu (shared loader); internal routes are loopback-only; pastes copy; vendor sync
Changes
@@@ -265,6 +265,11 @@<a href="#" onclick="event.preventDefault();openSetupHub()"><i class="fa-solid fa-list-check"></i> Streamer setup</a><a href="https://openvibe.network/admin" target="_blank" rel="noopener" id="user-dropdown-admin" style="display:none"><i class="fa-solid fa-shield-halved"></i> Admin Panel</a></div>++ <div class="ud-group" id="ud-display">++ <span class="ud-label">Display</span>++ <a href="#" data-ov-display="text"><i class="fa-solid fa-text-height"></i> Text size <b data-v style="margin-left:auto;font-weight:600;opacity:.8"></b></a>++ <a href="#" data-ov-display="motion"><i class="fa-solid fa-wand-magic-sparkles"></i> Animations <b data-v style="margin-left:auto;font-weight:600;opacity:.8"></b></a>++ </div><div class="ud-group"><span class="ud-label">Across OpenVibe</span><a href="https://openvibe.network"><i class="fa-solid fa-network-wired"></i> OpenVibe Network</a>@@@ -537,7 +542,7 @@<div class="home-cta-feature"><i class="fa-solid fa-paste"></i><strong>Pastes & file sharing</strong>-- <span>Share code, text and files with syntax highlighting — a full Pastebin, built right into your channel.</span>++ <span>Share code, logs and files with syntax highlighting and a short link. Pastes live on OpenVibe.Community and still show up on your channel.</span></div><div class="home-cta-feature"><i class="fa-solid fa-screwdriver-wrench"></i>@@@ -1201,6 +1206,7 @@<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── --><script src="/shared/theme-loader.js" defer></script>++<script defer src="/js/ov-display-menu.js"></script><script src="/shared/notification-ui.js" defer></script><!-- The requested route's scripts (public/features.json), added by the server after the core ones.Other routes load theirs on navigation through js/ov-loader.js. -->nnew file mode 100644@@@ -0,0 +1,15 @@++/* Display settings in the user menu. The state lives in the shared theme loader++ * (OpenVibeThemeLoader.display): same keys, same account sync, same behaviour as every other OpenVibe site. */++(function () {++ 'use strict';++ var box = document.getElementById('ud-display'), L = window.OpenVibeThemeLoader;++ if (!box || !L || !L.display) { if (box) box.style.display = 'none'; return; }++ var LABEL = { text: { '100': 'Default', '112': 'Large', '125': 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };++ function paint() { var d = L.display.get(); box.querySelectorAll('[data-ov-display]').forEach(function (a) { var k = a.getAttribute('data-ov-display'); a.querySelector('[data-v]').textContent = LABEL[k][d[k]]; }); }++ box.addEventListener('click', function (e) {++ var a = e.target.closest('[data-ov-display]'); if (!a) return; e.preventDefault(); e.stopPropagation();++ var k = a.getAttribute('data-ov-display'), opts = L.display.options[k], cur = L.display.get()[k], patch = {};++ patch[k] = opts[(opts.indexOf(cur) + 1) % opts.length]; L.display.set(patch); paint();++ });++ window.addEventListener('ov:display', paint); paint();++})();@@@ -577,7 +577,7 @@ app.post('/internal/media-webhook', require('./media-proxy/webhook'));// Internal key only; returns totals, never rows.app.get('/internal/analytics-summary', (req, res) => {const key = req.headers['x-internal-key'];-- if (!config.internalApiKey || !key || key !== config.internalApiKey) return res.status(401).json({ ok: false });++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || !config.internalApiKey || !key || key !== config.internalApiKey) return res.status(401).json({ ok: false });try {const days = Math.min(parseInt(req.query.days, 10) || 7, 90);const st = analytics.getStats({ days }) || {};@@@ -7,7 +7,9 @@ const db = require('../db/database');function requireInternalKey(req, res, next) {const key = req.headers['x-internal-key'];-- if (!key || key !== config.internalApiKey) {++ // Service-to-service only: nginx adds X-Forwarded-For to everything from outside, loopback callers never do.++ const viaProxy = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip'];++ if (viaProxy || !key || !config.internalApiKey || key !== config.internalApiKey) {return res.status(403).json({ error: 'Invalid or missing internal key' });}next();@@@ -99,6 +99,14 @@.ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }.ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }.ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }++ .ovnav-launcher .ovl-display { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 10px 4px 2px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #7d8aa0); }++ .ovnav-launcher .ovl-display[hidden] { display: none; }++ .ovnav-launcher .ovl-dl { font-weight: 700; letter-spacing: .6px; text-transform: uppercase; font-size: 11px; margin-right: 2px; }++ .ovnav-launcher .ovl-seg { display: inline-flex; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 9px; overflow: hidden; }++ .ovnav-launcher .ovl-seg button { all: unset; cursor: pointer; padding: 5px 9px; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }++ .ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }++ .ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }++ .ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }@@@ -643,7 +651,8 @@if (panel && panel.classList.contains('open')) return close();if (!panel) {panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');-- panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div>';++ panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div><div class="ovl-display" hidden></div>';++ bindDisplayControls(panel.querySelector('.ovl-display'));nav.appendChild(panel);if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }let cat = null; paint(null, '');@@@ -691,6 +700,21 @@return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');}++ /** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */++ function bindDisplayControls(box) {++ const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;++ const paint = () => {++ 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>`;++ 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(); });++ root.addEventListener('ov:display', paint);++ paint();++ }++// ── Notification bell (mounted by the navbar itself) ─────function mountBell(nav, u) {if (_config.notifications === false || !u || u.is_anon || !_config.token) return;@@@ -241,11 +241,47 @@}// ── Auto-apply on load (synchronous, prevents FOUC) ─────++ // ── Display preferences: motion + text size, one setting for every OpenVibe site ─────────────++ // OpenVibeThemeLoader.display.get() → { motion: 'auto'|'reduced', text: '100'|'112'|'125' }++ // 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'] };++ function readDisplay() {++ const out = { motion: 'auto', text: '100' };++ 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;++ }++ function applyDisplay(d) {++ if (typeof document === 'undefined') return;++ const el = document.documentElement;++ if (d.motion === 'reduced') el.setAttribute('data-ov-motion', 'reduced'); else el.removeAttribute('data-ov-motion');++ if (d.text === '100') { el.style.removeProperty('--ov-text-scale'); el.removeAttribute('data-ov-text'); } else { el.style.setProperty('--ov-text-scale', String(parseInt(d.text, 10) / 100)); el.setAttribute('data-ov-text', d.text); }++ if (!document.getElementById('ov-display-css') && document.head) {++ const st = document.createElement('style'); st.id = 'ov-display-css';++ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-mark .o,html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}';++ document.head.appendChild(st);++ }++ try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }++ }++ function setDisplay(patch, opts) {++ const d = readDisplay();++ 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 (!(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 () {});++ }++ return d;++ }++ applyDisplay(readDisplay());++const activeId = resolveAndApply();// 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 () { _chromeKey = ''; syncChrome(); });// 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 (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) applyDisplay(readDisplay()); });// 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'@@@ -271,6 +307,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.theme_id) return;const serverId = data.theme_id;const custom = (data.custom_variables && typeof data.custom_variables === 'object'@@@ -292,6 +329,7 @@// ── Public API ───────────────────────────────────────────const OpenVibeThemeLoader = {THEMES: THEMES,++ display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS },apply: applyById,applyVars: applyVars,save: save,
@@ -265,6 +265,11 @@
@@ -265,6 +265,11 @@
<a href="#" onclick="event.preventDefault();openSetupHub()"><i class="fa-solid fa-list-check"></i> Streamer setup</a>
<a href="#" onclick="event.preventDefault();openSetupHub()"><i class="fa-solid fa-list-check"></i> Streamer setup</a>
<a href="https://openvibe.network/admin" target="_blank" rel="noopener" id="user-dropdown-admin" style="display:none"><i class="fa-solid fa-shield-halved"></i> Admin Panel</a>
<a href="https://openvibe.network/admin" target="_blank" rel="noopener" id="user-dropdown-admin" style="display:none"><i class="fa-solid fa-shield-halved"></i> Admin Panel</a>
</div>
</div>
+ <div class="ud-group" id="ud-display">
+ <span class="ud-label">Display</span>
+ <a href="#" data-ov-display="text"><i class="fa-solid fa-text-height"></i> Text size <b data-v style="margin-left:auto;font-weight:600;opacity:.8"></b></a>
+ <a href="#" data-ov-display="motion"><i class="fa-solid fa-wand-magic-sparkles"></i> Animations <b data-v style="margin-left:auto;font-weight:600;opacity:.8"></b></a>
+ </div>
<div class="ud-group">
<div class="ud-group">
<span class="ud-label">Across OpenVibe</span>
<span class="ud-label">Across OpenVibe</span>
<a href="https://openvibe.network"><i class="fa-solid fa-network-wired"></i> OpenVibe Network</a>
<a href="https://openvibe.network"><i class="fa-solid fa-network-wired"></i> OpenVibe Network</a>
@@ -537,7 +542,7 @@
@@ -537,7 +542,7 @@
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-paste"></i>
<i class="fa-solid fa-paste"></i>
<strong>Pastes & file sharing</strong>
<strong>Pastes & file sharing</strong>
- <span>Share code, text and files with syntax highlighting — a full Pastebin, built right into your channel.</span>
+ <span>Share code, logs and files with syntax highlighting and a short link. Pastes live on OpenVibe.Community and still show up on your channel.</span>
</div>
</div>
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-screwdriver-wrench"></i>
<i class="fa-solid fa-screwdriver-wrench"></i>
@@ -1201,6 +1206,7 @@
@@ -1201,6 +1206,7 @@
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js" defer></script>
<script src="/shared/theme-loader.js" defer></script>
+<script defer src="/js/ov-display-menu.js"></script>
<script src="/shared/notification-ui.js" defer></script>
<script src="/shared/notification-ui.js" defer></script>
<!-- The requested route's scripts (public/features.json), added by the server after the core ones.
<!-- The requested route's scripts (public/features.json), added by the server after the core ones.
Other routes load theirs on navigation through js/ov-loader.js. -->
Other routes load theirs on navigation through js/ov-loader.js. -->
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+/* Display settings in the user menu. The state lives in the shared theme loader
+ * (OpenVibeThemeLoader.display): same keys, same account sync, same behaviour as every other OpenVibe site. */
+(function () {
+ 'use strict';
+ var box = document.getElementById('ud-display'), L = window.OpenVibeThemeLoader;
+ if (!box || !L || !L.display) { if (box) box.style.display = 'none'; return; }
+ var LABEL = { text: { '100': 'Default', '112': 'Large', '125': 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };
+ function paint() { var d = L.display.get(); box.querySelectorAll('[data-ov-display]').forEach(function (a) { var k = a.getAttribute('data-ov-display'); a.querySelector('[data-v]').textContent = LABEL[k][d[k]]; }); }
+ box.addEventListener('click', function (e) {
+ var a = e.target.closest('[data-ov-display]'); if (!a) return; e.preventDefault(); e.stopPropagation();
+ var k = a.getAttribute('data-ov-display'), opts = L.display.options[k], cur = L.display.get()[k], patch = {};
+ patch[k] = opts[(opts.indexOf(cur) + 1) % opts.length]; L.display.set(patch); paint();
+ });
+ window.addEventListener('ov:display', paint); paint();
+})();
@@ -577,7 +577,7 @@ app.post('/internal/media-webhook', require('./media-proxy/webhook'));
@@ -577,7 +577,7 @@ app.post('/internal/media-webhook', require('./media-proxy/webhook'));
// Internal key only; returns totals, never rows.
// Internal key only; returns totals, never rows.
app.get('/internal/analytics-summary', (req, res) => {
app.get('/internal/analytics-summary', (req, res) => {
const key = req.headers['x-internal-key'];
const key = req.headers['x-internal-key'];
- if (!config.internalApiKey || !key || key !== config.internalApiKey) return res.status(401).json({ ok: false });
+ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || !config.internalApiKey || !key || key !== config.internalApiKey) return res.status(401).json({ ok: false });
try {
try {
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const st = analytics.getStats({ days }) || {};
const st = analytics.getStats({ days }) || {};
@@ -7,7 +7,9 @@ const db = require('../db/database');
@@ -7,7 +7,9 @@ const db = require('../db/database');
function requireInternalKey(req, res, next) {
function requireInternalKey(req, res, next) {
const key = req.headers['x-internal-key'];
const key = req.headers['x-internal-key'];
- if (!key || key !== config.internalApiKey) {
+ // Service-to-service only: nginx adds X-Forwarded-For to everything from outside, loopback callers never do.
+ const viaProxy = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip'];
+ if (viaProxy || !key || !config.internalApiKey || key !== config.internalApiKey) {
return res.status(403).json({ error: 'Invalid or missing internal key' });
return res.status(403).json({ error: 'Invalid or missing internal key' });
}
}
next();
next();
@@ -99,6 +99,14 @@
@@ -99,6 +99,14 @@
.ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
.ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
.ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }
.ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }
.ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }
.ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }
+ .ovnav-launcher .ovl-display { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 10px 4px 2px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #7d8aa0); }
+ .ovnav-launcher .ovl-display[hidden] { display: none; }
+ .ovnav-launcher .ovl-dl { font-weight: 700; letter-spacing: .6px; text-transform: uppercase; font-size: 11px; margin-right: 2px; }
+ .ovnav-launcher .ovl-seg { display: inline-flex; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 9px; overflow: hidden; }
+ .ovnav-launcher .ovl-seg button { all: unset; cursor: pointer; padding: 5px 9px; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
+ .ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
+ .ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
+ .ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@@ -643,7 +651,8 @@
@@ -643,7 +651,8 @@
if (panel && panel.classList.contains('open')) return close();
if (panel && panel.classList.contains('open')) return close();
if (!panel) {
if (!panel) {
panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');
panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');
- panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div>';
+ panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div><div class="ovl-display" hidden></div>';
+ bindDisplayControls(panel.querySelector('.ovl-display'));
nav.appendChild(panel);
nav.appendChild(panel);
if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
let cat = null; paint(null, '');
let cat = null; paint(null, '');
@@ -691,6 +700,21 @@
@@ -691,6 +700,21 @@
return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
}
}
+ /** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */
+ function bindDisplayControls(box) {
+ const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;
+ const paint = () => {
+ 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>`;
+ 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(); });
+ root.addEventListener('ov:display', paint);
+ paint();
+ }
+
// ── Notification bell (mounted by the navbar itself) ─────
// ── Notification bell (mounted by the navbar itself) ─────
function mountBell(nav, u) {
function mountBell(nav, u) {
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
@@ -241,11 +241,47 @@
@@ -241,11 +241,47 @@
}
}
// ── Auto-apply on load (synchronous, prevents FOUC) ─────
// ── Auto-apply on load (synchronous, prevents FOUC) ─────
+ // ── Display preferences: motion + text size, one setting for every OpenVibe site ─────────────
+ // OpenVibeThemeLoader.display.get() → { motion: 'auto'|'reduced', text: '100'|'112'|'125' }
+ // 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'] };
+ function readDisplay() {
+ const out = { motion: 'auto', text: '100' };
+ 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;
+ }
+ function applyDisplay(d) {
+ if (typeof document === 'undefined') return;
+ const el = document.documentElement;
+ if (d.motion === 'reduced') el.setAttribute('data-ov-motion', 'reduced'); else el.removeAttribute('data-ov-motion');
+ if (d.text === '100') { el.style.removeProperty('--ov-text-scale'); el.removeAttribute('data-ov-text'); } else { el.style.setProperty('--ov-text-scale', String(parseInt(d.text, 10) / 100)); el.setAttribute('data-ov-text', d.text); }
+ if (!document.getElementById('ov-display-css') && document.head) {
+ const st = document.createElement('style'); st.id = 'ov-display-css';
+ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-mark .o,html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}';
+ document.head.appendChild(st);
+ }
+ try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
+ }
+ function setDisplay(patch, opts) {
+ const d = readDisplay();
+ 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 (!(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 () {});
+ }
+ return d;
+ }
+ applyDisplay(readDisplay());
+
const activeId = resolveAndApply();
const activeId = resolveAndApply();
// 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 () { _chromeKey = ''; syncChrome(); });
if (typeof document !== 'undefined' && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { _chromeKey = ''; syncChrome(); });
// 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 (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) applyDisplay(readDisplay()); });
// 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'
@@ -271,6 +307,7 @@
@@ -271,6 +307,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.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'
@@ -292,6 +329,7 @@
@@ -292,6 +329,7 @@
// ── Public API ───────────────────────────────────────────
// ── Public API ───────────────────────────────────────────
const OpenVibeThemeLoader = {
const OpenVibeThemeLoader = {
THEMES: THEMES,
THEMES: THEMES,
+ display: { get: readDisplay, set: setDisplay, options: DISPLAY_OPTS },
apply: applyById,
apply: applyById,
applyVars: applyVars,
applyVars: applyVars,
save: save,
save: save,