openvibers/openvibe.design Commits

Design /look: a different look on one site (Games in Neon Tokyo); openvibe-shared v4.43.0 (#20)

OP OpenVibers committed 15 days ago · 60e10c5c34 · 11 files, +117 −11

Signed in, /look lists the person's per-site theme exceptions and adds or removes one (site + theme). The proxy forwards to Network's /api/themes/me/overrides (Network#419) as the person, writes only from openvibe.design, and refuses a bad site id. Sites wear the exception through openvibe-shared 4.43.0's theme-loader (?service=).

Changes

ddiff --git a/README.md b/README.md
iindex 1a7c786..e9f4c52 100644
---- a/README.md
++++ b/README.md
@@@ -197,5 +197,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- versions:start -->
- openvibe-contracts: v0.236.0
- openvibe-sdk: v0.70.0
--- openvibe-shared: v4.42.0
++- openvibe-shared: v4.43.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex b6755bf..a2b2c9d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,11 +10,11 @@
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.236.0",
"sdk": "openvibe-sdk v0.70.0",
-- "shared": "openvibe-shared v4.42.0",
++ "shared": "openvibe-shared v4.43.0",
"packages": [
"openvibe-contracts v0.236.0",
"openvibe-sdk v0.70.0",
-- "openvibe-shared v4.42.0"
++ "openvibe-shared v4.43.0"
],
"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"works": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 55a2e4e..e61657b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.42.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
"pg": "^8.23.1"
},
"devDependencies": {
@@@ -1114,9 +1114,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.42.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.42.0",
-- "integrity": "sha512-Nrbbi3zZjD492EsicO49UWv7sJRj368BDvFdO3DhQBaUJFYSuVAiAAbjuPz5pDT0h7EyTTz71mSkY59hSQbFwA==",
++ "version": "4.43.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
++ "integrity": "sha512-OANEzCEB2QPgbkb9Yt0O67k6DxcK2pHMO1nhKYCjTI7OhUF8LW8EpLf6FJmpHmDTmpFTUq4pJ3lr3u2hPXMDYQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex a5635c2..555af3a 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.42.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
"pg": "^8.23.1"
},
"engines": {
ddiff --git a/public/css/design.css b/public/css/design.css
iindex 17ed232..43fa194 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -467,3 +467,12 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.ds-code-pick select { min-height: 44px; padding: 0 12px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-input); color: var(--ovk-text); font: inherit; }
#ds-code-sheet .ovk-snip pre { max-height: 52vh; white-space: pre; overflow: auto; }
.ds-code-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 10px 0 0; }
++
++/* /look: a different look on one site */
++.lk-sites { list-style: none; margin: 0; padding: 0 14px; }
++.lk-sites li { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 52px; border-bottom: 1px solid var(--ovk-line); }
++.lk-sites .lk-none { color: var(--ovk-muted); justify-content: flex-start; }
++.lk-site-form { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; padding: 12px 14px 14px; }
++.lk-site-form label { display: grid; gap: 4px; font-size: .86rem; font-weight: 700; color: var(--ovk-muted); }
++.lk-site-form select { min-height: 44px; max-width: 70vw; padding: 0 10px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-input); color: var(--ovk-text); font: inherit; }
++.lk-site-form .ovk-btn { min-height: 44px; }
ddiff --git a/public/js/look.js b/public/js/look.js
iindex dc7631f..fb6c60a 100644
---- a/public/js/look.js
++++ b/public/js/look.js
@@@ -50,6 +50,41 @@
showSettings();
say(root.getAttribute('data-signed-in') === '1' ? 'Saved. Every OpenVibe site follows.' : 'Saved on this device.');
});
++ // ── A different look on one site (signed in): list, add, remove, through /api/me/theme/overrides ──
++ const siteList = $('#lk-site-list');
++ const siteName = (id) => { const o = root.querySelector(`#lk-site-form select[name="service"] option[value="${id}"]`); return o ? o.textContent : id; };
++ const api = (path, opts = {}) => fetch(path, { credentials: 'same-origin', ...opts, headers: { 'content-type': 'application/json', ...(opts.headers || {}) } }).then((r) => (r.ok ? r.json().catch(() => ({})) : Promise.reject(r)));
++ function drawSites(items) {
++ if (!siteList) return;
++ siteList.replaceChildren();
++ if (!items.length) { const li = document.createElement('li'); li.className = 'lk-none'; li.textContent = 'None yet: every site wears your theme.'; siteList.appendChild(li); return; }
++ for (const o of items) {
++ const li = document.createElement('li');
++ const t = byId.get(o.theme_id);
++ const txt = document.createElement('span');
++ const b = document.createElement('b'); b.textContent = siteName(o.service);
++ txt.append(b, ` wears ${(t && t.name) || o.theme_name || o.theme_id}`);
++ const rm = document.createElement('button');
++ rm.type = 'button'; rm.className = 'ovk-btn ovk-btn--quiet ovk-btn--small'; rm.textContent = 'Remove';
++ rm.addEventListener('click', () => api(`/api/me/theme/overrides/${encodeURIComponent(o.service)}`, { method: 'DELETE' }).then(loadSites).then(() => say(`${siteName(o.service)} wears your theme again.`)).catch(() => say('That did not work. Try again.')));
++ li.append(txt, rm);
++ siteList.appendChild(li);
++ }
++ }
++ const loadSites = () => api('/api/me/theme/overrides').then((d) => drawSites((d && d.overrides) || [])).catch(() => drawSites([]));
++ const siteForm = $('#lk-site-form');
++ if (siteForm && siteList) {
++ loadSites();
++ siteForm.addEventListener('submit', (e) => {
++ e.preventDefault();
++ const service = siteForm.elements.service.value;
++ const themeId = siteForm.elements.theme_id.value;
++ api(`/api/me/theme/overrides/${encodeURIComponent(service)}`, { method: 'PUT', body: JSON.stringify({ theme_id: themeId }) })
++ .then(loadSites).then(() => say(`${siteName(service)} wears ${(byId.get(themeId) || {}).name || themeId} from its next page.`))
++ .catch(() => say('That did not work. Try again.'));
++ });
++ }
++
// The account's answer, another tab or the device can change the look while the page is open.
window.addEventListener('storage', () => { showTheme(); showSettings(); });
new MutationObserver(showTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 7abe587..4dc4239 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -12,6 +12,8 @@
* GET /api/me/theme/history Network GET /api/themes/me/history
* POST /api/me/theme/history/:id/restore Network's restore (id a positive integer)
* POST /api/themes/submit Network POST /api/themes
++ * GET /api/me/theme/overrides Network GET /api/themes/me/overrides (a theme for one site)
++ * PUT|DELETE /api/me/theme/overrides/:service Network's PUT|DELETE /api/themes/me/overrides/:service
* GET /api/v1/tokens the export targets and the token names (OpenVibe Tokens)
* GET /api/v1/components the components' contract (openvibe-shared/components)
* POST /api/v1/components/validate { doc } → { ok, errors }: a kit document checked against it
@@@ -182,6 +184,21 @@ function createApi(ctx) {
}
});
++ // A theme for one site (Network#419): list, set, remove. The service is a short lowercase id.
++ const SITE = /^[a-z][a-z0-9-]{1,31}$/;
++ r.get('/me/theme/overrides', requireToken, async (req, res) => {
++ try { return forward(res, req, await catalog.request('/me/overrides', { token: req.themeToken })); } catch (err) { return failure(res, req, err); }
++ });
++ r.put('/me/theme/overrides/:service', requireOrigin, requireToken, async (req, res) => {
++ if (!SITE.test(req.params.service)) return http.sendProblem(res, 400, 'theme.site', { detail: 'Not a site OpenVibe knows.', ctx: req.ov });
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ try { return forward(res, req, await catalog.request(`/me/overrides/${req.params.service}`, { method: 'PUT', token: req.themeToken, body: { theme_id: b.theme_id } })); } catch (err) { return failure(res, req, err); }
++ });
++ r.delete('/me/theme/overrides/:service', requireOrigin, requireToken, async (req, res) => {
++ if (!SITE.test(req.params.service)) return http.sendProblem(res, 400, 'theme.site', { detail: 'Not a site OpenVibe knows.', ctx: req.ov });
++ try { return forward(res, req, await catalog.request(`/me/overrides/${req.params.service}`, { method: 'DELETE', token: req.themeToken })); } catch (err) { return failure(res, req, err); }
++ });
++
r.get('/me/theme/history', requireToken, async (req, res) => {
try {
const up = await catalog.request('/me/history', { token: req.themeToken });
ddiff --git a/server/http/router.js b/server/http/router.js
iindex 31ad688..f1329ee 100644
---- a/server/http/router.js
++++ b/server/http/router.js
@@@ -8,7 +8,7 @@ const express = require('express');
function asyncRouter() {
const r = express.Router();
-- for (const method of ['get', 'post', 'put']) {
++ for (const method of ['get', 'post', 'put', 'delete']) {
const orig = r[method].bind(r);
r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
try {
ddiff --git a/server/views/look.js b/server/views/look.js
iindex 8ebc87b..1d16804 100644
---- a/server/views/look.js
++++ b/server/views/look.js
@@@ -10,6 +10,9 @@
const kit = require('openvibe-shared/kit');
const { layout, esc } = require('../render/layout');
++// The sites a person can give a look of their own (service ids, as openvibe-shared/shell writes them on <html>).
++const SITES = [['live', 'OpenVibe.Live'], ['chat', 'OpenVibe.Chat'], ['games', 'OpenVibe.Games'], ['community', 'OpenVibe.Community'], ['media', 'OpenVibe.Media'], ['tools', 'OpenVibe.Tools'], ['bot', 'OpenVibe.Bot'], ['actor', 'OpenVibe.Actor'], ['quest', 'OpenVibe.Quest'], ['rent', 'OpenVibe.Rent'], ['deals', 'OpenVibe.Deals'], ['network', 'OpenVibe.Network'], ['design', 'OpenVibe.Design']];
++
function lookPage({ themes = [], me = {} } = {}) {
const list = (themes || []).filter((t) => t && t.id).map((t) => ({ id: t.id, name: t.name, mode: t.mode, dots: ['--bg-primary', '--bg-card', '--accent', '--text-primary'].map((k) => String((t.variables || {})[k] || '')).filter((v) => /^#[0-9a-f]{3,8}$/i.test(v)) }));
const signedIn = !!me.signedIn;
@@@ -25,14 +28,23 @@ function lookPage({ themes = [], me = {} } = {}) {
+ kit.seg({ name: 'motion', label: 'Motion', options: [['auto', 'Full'], ['reduced', 'Reduced']], value: 'auto' })
+ kit.switchRow({ name: 'seasonal', label: 'The season\'s themes', hint: 'Halloween and winter stand in for the default theme while they last', on: true }),
});
++ const themeOpts = list.map((t) => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('');
++ const sites = signedIn ? kit.section({
++ id: 'lk-sites',
++ title: 'A different look on one site',
++ note: 'Wear another theme on one site only: Games in Neon Tokyo, Live in Daylight. Everywhere else keeps your theme.',
++ body: `<ul class="lk-sites" id="lk-site-list" aria-live="polite"></ul>
++<form class="lk-site-form" id="lk-site-form"><label><span>Site</span><select name="service">${SITES.map(([id, name]) => `<option value="${esc(id)}">${esc(name)}</option>`).join('')}</select></label>
++<label><span>Theme</span><select name="theme_id">${themeOpts}</select></label>${kit.button({ type: 'submit', label: 'Add', small: true })}</form>`,
++ }) : '';
const body = `<header class="ds-page-h"><p class="ds-kicker">Your look</p><h1>Your look, everywhere</h1>
<p class="ds-lede">One theme and one set of comfort settings for every OpenVibe site: Live, Chat, Games, the shops and the rest.</p></header>
<noscript>${kit.notice({ kind: 'info', text: 'These settings need JavaScript in this browser. Your theme still follows your account.' })}</noscript>
--<div class="lk" data-signed-in="${signedIn ? '1' : '0'}">${current}${settings}
++<div class="lk" data-signed-in="${signedIn ? '1' : '0'}">${current}${settings}${sites}
<p class="lk-status" id="lk-status" role="status"></p>
${signedIn ? `<p class="ds-fine">Every change keeps the look it replaced: <a href="/studio#ds-history">your history</a> brings any of the last fifty back.</p>` : `<p>${kit.button({ href: '/auth/login?next=%2Flook', label: 'Sign in so it follows you', kind: 'quiet', small: true })}</p>`}</div>
<script type="application/json" id="lk-themes">${JSON.stringify(list).replace(/</g, '\\u003c')}</script>`;
return layout({ title: 'Your look', description: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.', path: '/look', body: `<div class="ds-wrap tk">${body}</div>`, active: '/look', scripts: ['js/look.js'] });
}
--module.exports = { lookPage, esc };
++module.exports = { lookPage, esc, SITES };
ddiff --git a/test/helpers/network.js b/test/helpers/network.js
iindex e31eb72..a73317a 100644
---- a/test/helpers/network.js
++++ b/test/helpers/network.js
@@@ -132,6 +132,20 @@ async function startNetwork({ themes = DEFAULT_THEMES } = {}) {
if (!bearer(req)) return json(401, { error: 'Authentication required' });
return json(200, { success: true, theme_id: body.theme_id, custom_variables: body.custom_variables || null, source: body.source });
}
++ // Per-site overrides (Network#419), kept in memory per test run.
++ st.overrides = st.overrides || new Map();
++ if (url.pathname === '/api/themes/me/overrides' && req.method === 'GET') {
++ if (!bearer(req)) return json(401, { error: 'Authentication required' });
++ return json(200, { overrides: [...st.overrides.entries()].map(([service, theme_id]) => ({ service, theme_id })) });
++ }
++ const ov = /^\/api\/themes\/me\/overrides\/([a-z][a-z0-9-]{1,31})$/.exec(url.pathname);
++ if (ov && (req.method === 'PUT' || req.method === 'DELETE')) {
++ if (!bearer(req)) return json(401, { error: 'Authentication required' });
++ if (req.method === 'DELETE') return st.overrides.delete(ov[1]) ? json(200, { success: true, service: ov[1], deleted: true }) : json(404, { error: 'No override' });
++ if (!st.themes.some((t) => t.id === body.theme_id)) return json(404, { error: 'Theme not found' });
++ st.overrides.set(ov[1], body.theme_id);
++ return json(200, { success: true, service: ov[1], theme_id: body.theme_id, override: true });
++ }
if (url.pathname === '/api/themes/me/history' && req.method === 'GET') {
if (!bearer(req)) return json(401, { error: 'Authentication required' });
return json(200, { history: [{ id: 2, theme_id: 'aurora', created_at: '2026-10-28T00:00:00.000Z' }, { id: 1, theme_id: 'vibe', created_at: '2026-10-27T00:00:00.000Z' }] });
ddiff --git a/test/tokens.test.js b/test/tokens.test.js
iindex 0158cc0..9ebd544 100644
---- a/test/tokens.test.js
++++ b/test/tokens.test.js
@@@ -133,6 +133,25 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(bad.json().errors.join(' '), /card\.props\.href: expected url/);
});
++ await check('/look: a different look on one site, through the proxy, as the person', async () => {
++ const kim = t.network.addUser('kim-sites');
++ const page = await t.get('/look', { as: kim });
++ assert.match(page.text, /A different look on one site/);
++ assert.match(page.text, /<select name="service"><option value="live">OpenVibe\.Live<\/option>/);
++ assert.doesNotMatch((await t.get('/look')).text, /A different look on one site/, 'signed out: no section');
++ const origin = 'https://openvibe.design';
++ const put = await t.get('/api/me/theme/overrides/games', { as: kim, method: 'PUT', json: { theme_id: 'vibe' }, origin });
++ assert.strictEqual(put.status, 200, put.text);
++ const list = (await t.get('/api/me/theme/overrides', { as: kim })).json();
++ assert.deepStrictEqual(list.overrides, [{ service: 'games', theme_id: 'vibe' }]);
++ assert.strictEqual((await t.get('/api/me/theme/overrides/games', { as: kim, method: 'PUT', json: { theme_id: 'vibe' }, origin: 'https://evil.example' })).status, 403, 'writes come from openvibe.design');
++ assert.strictEqual((await t.get('/api/me/theme/overrides/Games!', { as: kim, method: 'PUT', json: { theme_id: 'vibe' }, origin })).status, 400, 'a bad site id');
++ assert.strictEqual((await t.get('/api/me/theme/overrides')).status, 401, 'signed out');
++ const del = await t.get('/api/me/theme/overrides/games', { as: kim, method: 'DELETE', origin });
++ assert.strictEqual(del.status, 200);
++ assert.deepStrictEqual((await t.get('/api/me/theme/overrides', { as: kim })).json().overrides, []);
++ });
++
await t.close();
done();
})();