openvibers/openvibe.design Commits
Design /look: a different look on one site (Games in Neon Tokyo); openvibe-shared v4.43.0 (#20)
Changes
@@@ -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 -->@@@ -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": [@@@ -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",@@@ -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": {@@@ -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; }@@@ -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'] });@@@ -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 });@@@ -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 {@@@ -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 };@@@ -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' }] });@@@ -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();})();
@@ -197,5 +197,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
@@ -197,5 +197,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.236.0
- openvibe-contracts: v0.236.0
- openvibe-sdk: v0.70.0
- openvibe-sdk: v0.70.0
-- openvibe-shared: v4.42.0
+- openvibe-shared: v4.43.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,11 +10,11 @@
@@ -10,11 +10,11 @@
"domain": "openvibe.design",
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.236.0",
"contracts": "openvibe-contracts v0.236.0",
"sdk": "openvibe-sdk v0.70.0",
"sdk": "openvibe-sdk v0.70.0",
- "shared": "openvibe-shared v4.42.0",
+ "shared": "openvibe-shared v4.43.0",
"packages": [
"packages": [
"openvibe-contracts v0.236.0",
"openvibe-contracts v0.236.0",
"openvibe-sdk v0.70.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.",
"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": [
"works": [
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.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-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"
"pg": "^8.23.1"
},
},
"devDependencies": {
"devDependencies": {
@@ -1114,9 +1114,9 @@
@@ -1114,9 +1114,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.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-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"
"pg": "^8.23.1"
},
},
"engines": {
"engines": {
@@ -467,3 +467,12 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -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-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-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; }
.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; }
@@ -50,6 +50,41 @@
@@ -50,6 +50,41 @@
showSettings();
showSettings();
say(root.getAttribute('data-signed-in') === '1' ? 'Saved. Every OpenVibe site follows.' : 'Saved on this device.');
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.
// The account's answer, another tab or the device can change the look while the page is open.
window.addEventListener('storage', () => { showTheme(); showSettings(); });
window.addEventListener('storage', () => { showTheme(); showSettings(); });
new MutationObserver(showTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
new MutationObserver(showTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
@@ -12,6 +12,8 @@
@@ -12,6 +12,8 @@
* GET /api/me/theme/history Network GET /api/themes/me/history
* 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/me/theme/history/:id/restore Network's restore (id a positive integer)
* POST /api/themes/submit Network POST /api/themes
* 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/tokens the export targets and the token names (OpenVibe Tokens)
* GET /api/v1/components the components' contract (openvibe-shared/components)
* GET /api/v1/components the components' contract (openvibe-shared/components)
* POST /api/v1/components/validate { doc } → { ok, errors }: a kit document checked against it
* POST /api/v1/components/validate { doc } → { ok, errors }: a kit document checked against it
@@ -182,6 +184,21 @@ function createApi(ctx) {
@@ -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) => {
r.get('/me/theme/history', requireToken, async (req, res) => {
try {
try {
const up = await catalog.request('/me/history', { token: req.themeToken });
const up = await catalog.request('/me/history', { token: req.themeToken });
@@ -8,7 +8,7 @@ const express = require('express');
@@ -8,7 +8,7 @@ const express = require('express');
function asyncRouter() {
function asyncRouter() {
const r = express.Router();
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);
const orig = r[method].bind(r);
r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
try {
try {
@@ -10,6 +10,9 @@
@@ -10,6 +10,9 @@
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { layout, esc } = require('../render/layout');
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 = {} } = {}) {
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 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;
const signedIn = !!me.signedIn;
@@ -25,14 +28,23 @@ function lookPage({ themes = [], me = {} } = {}) {
@@ -25,14 +28,23 @@ function lookPage({ themes = [], me = {} } = {}) {
+ kit.seg({ name: 'motion', label: 'Motion', options: [['auto', 'Full'], ['reduced', 'Reduced']], value: 'auto' })
+ 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 }),
+ 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>
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>
<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>
<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>
<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>
${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>`;
<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'] });
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 };
@@ -132,6 +132,20 @@ async function startNetwork({ themes = DEFAULT_THEMES } = {}) {
@@ -132,6 +132,20 @@ async function startNetwork({ themes = DEFAULT_THEMES } = {}) {
if (!bearer(req)) return json(401, { error: 'Authentication required' });
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 });
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 (url.pathname === '/api/themes/me/history' && req.method === 'GET') {
if (!bearer(req)) return json(401, { error: 'Authentication required' });
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' }] });
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' }] });
@@ -133,6 +133,25 @@ const { boot, check, done } = require('./helpers/boot');
@@ -133,6 +133,25 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(bad.json().errors.join(' '), /card\.props\.href: expected url/);
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();
await t.close();
done();
done();
})();
})();