openvibers/openvibe.sdk Commits

openvibe-sdk/themes: the account's theme, display preferences and seasons (createThemesClient), and followTheme for pages, canvases and games (#80)

OP OpenVibers committed 19 days ago · c08a271fa2 · 15 files, +497 −27

Changes

ddiff --git a/README.md b/README.md
iindex efe7be9..b0bb124 100644
---- a/README.md
++++ b/README.md
@@@ -173,6 +173,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) |
++| `openvibe-sdk/themes` | both | `createThemesClient(client)`: `catalog({mode, search, sort, limit})` (seasonal themes carry `season`), `get(idOrSlug)` (null when missing), `export(idOrSlug)`, `active()` (`{theme_id, custom_variables, display}`), `set(themeId, {custom})`, `setDisplay({motion, text, seasonal})`, `seasons({date})`, `submissions()`, `submit(theme)`, `import(file)`. In the browser: `followTheme(fn)` calls back now and on every theme, display or season change with `readTheme()` (`{id, mode, tokens, display: {motion, text, textScale, seasonal, calm}, season, seasonMark}`) and returns `stop()`, for a canvas or game to keep up; `applyTheme(vars, {root})`, `seasonOn(date, seasons)`, `onColor(hex)`, `DISPLAY_OPTIONS`, `TOKENS` |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |
ddiff --git a/browser.js b/browser.js
iindex 9790455..c824fb8 100644
---- a/browser.js
++++ b/browser.js
@@@ -15,6 +15,7 @@ module.exports = {
community: require('./src/community'),
bot: require('./src/bot'),
search: require('./src/search'),
++ themes: require('./src/themes'),
chat: require('./src/chat'),
ai: require('./src/ai'),
jobs: require('./src/jobs'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 4cd0eb8..a4085f2 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,6 +1,6 @@
// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.64.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, registry, modules, realtime, media, community, bot, search, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -22,12 +22,13 @@ module.exports = {
community: __load(14),
bot: __load(15),
search: __load(16),
-- chat: __load(17),
-- ai: __load(18),
-- jobs: __load(19),
-- tools: __load(21),
-- projects: __load(22),
-- geo: __load(23),
++ themes: __load(17),
++ chat: __load(18),
++ ai: __load(19),
++ jobs: __load(20),
++ tools: __load(22),
++ projects: __load(23),
++ geo: __load(24),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -1979,7 +1980,156 @@ function createSearchClient(client, defaults = {}) {
module.exports = { createSearchClient, queryOf };
},
--// ── 17: src/chat.js ─────────────────────────────────────────────────
++// ── 17: src/themes.js ───────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/themes: the OpenVibe theme system for any app. A person's theme, display preferences (motion, text size,
++ * seasonal themes) and the seasons follow their account to every OpenVibe site; an app reads and changes them here, and
++ * a page or a game follows them as they change. Browser-safe.
++ *
++ * The account (OpenVibe.Network /api/themes):
++ *
++ * const themes = createThemesClient(client); // client from openvibe-sdk/core (the person's sign-in)
++ * const { themes: list } = await themes.catalog({ mode: 'dark' }); // approved themes; seasonal ones carry `season`
++ * const theme = await themes.get('nord'); // null when missing
++ * const mine = await themes.active(); // { theme_id, custom_variables, display } (guests: theme_id null)
++ * await themes.set('nord'); // or set('vibe', { custom: { '--accent': '#ff5c8a' } })
++ * await themes.setDisplay({ motion: 'reduced', text: '112', seasonal: 'off' });
++ * const { seasons, now } = await themes.seasons(); // what stands in for the default theme when
++ *
++ * On a page (the theme loader every OpenVibe site runs, openvibe-sdk/frame, paints the theme; this follows it):
++ *
++ * const stop = followTheme(({ tokens, mode, display, season }) => {
++ * game.setPalette(tokens['--accent'], tokens['--bg-primary']); // a canvas or WebGL scene keeps up
++ * game.setCalm(display.motion === 'reduced'); // no screen shake, fewer particles
++ * game.setUiScale(display.textScale); // 1, 1.12 or 1.25
++ * });
++ *
++ * followTheme calls back once at once and again on every change (another tab, the account's answer, a season starting,
++ * a display preference), with the CSS variables on <html> read fresh; stop() ends it. readTheme() is the same snapshot
++ * on demand. applyTheme(vars) paints a theme's variables on an element (default <html>) for apps without the loader.
++ */
++const { isOpenVibeError } = __load(3);
++
++const enc = encodeURIComponent;
++const DISPLAY_OPTIONS = Object.freeze({ motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'] });
++/** The tokens every OpenVibe theme sets (the shared names apps style with). */
++const TOKENS = Object.freeze(['--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-card', '--bg-hover', '--text-primary', '--text-secondary', '--text-muted',
++ '--accent', '--accent-light', '--accent-dark', '--accent-strong', '--on-accent', '--on-accent-strong', '--accent-glow', '--border', '--border-light',
++ '--success', '--warning', '--danger', '--color-scheme']);
++
++function createThemesClient(client, { baseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createThemesClient: pass a client from openvibe-sdk/core');
++ const call = (opts, o = {}) => client.json({ service: 'network', baseUrl, ...opts, signal: o.signal });
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ const once = { idempotencyKey: false };
++ return {
++ /** query: mode (dark | light), search, sort (downloads | rating), limit → { themes } */
++ catalog: (query = {}, o = {}) => call({ path: '/api/themes', query }, o),
++ /** → the theme ({ id, name, slug, mode, variables, tags, … }) or null */
++ get: async (idOrSlug, o = {}) => { const d = await orNull(call({ path: `/api/themes/${enc(idOrSlug)}` }, o)); return d ? d.theme : null; },
++ /** An openvibe-theme@1 file (import it elsewhere, or submit an edited copy), or null */
++ export: (idOrSlug, o = {}) => orNull(call({ path: `/api/themes/${enc(idOrSlug)}/export` }, o)),
++ /** The person's theme: { theme_id, custom_variables, display } ({ theme_id: null, guest: true } signed out) */
++ active: (o = {}) => call({ path: '/api/themes/me/active' }, o),
++ /** Save the person's theme everywhere; custom: their own overrides of its variables (colours, lengths, shadows) */
++ set: (themeId, { custom = null } = {}, o = {}) => call({ method: 'PUT', path: '/api/themes/me', json: { theme_id: themeId, custom_variables: custom }, ...once }, o),
++ /** motion auto | reduced, text 100 | 112 | 125, seasonal on | off → { display } (unknown values are dropped) */
++ setDisplay: (patch = {}, o = {}) => call({ method: 'PUT', path: '/api/themes/me/display', json: cleanDisplay(patch), ...once }, o),
++ /** query: date (YYYY-MM-DD) → { seasons: [{ id, name, theme, from, to }], now } */
++ seasons: (query = {}, o = {}) => call({ path: '/api/themes/seasons', query }, o),
++ /** The person's own submissions, with their review status */
++ submissions: (o = {}) => call({ path: '/api/themes/me/submissions' }, o),
++ /** Submit a theme ({ name, description?, mode, variables, tags? }): private to its author until approved */
++ submit: (theme, o = {}) => call({ method: 'POST', path: '/api/themes', json: theme, ...once }, o),
++ /** Submit an openvibe-theme@1 file */
++ import: (file, o = {}) => call({ method: 'POST', path: '/api/themes/import', json: file, ...once }, o),
++ };
++}
++
++function cleanDisplay(d = {}) {
++ const out = {};
++ for (const k of Object.keys(DISPLAY_OPTIONS)) if (d && DISPLAY_OPTIONS[k].includes(String(d[k]))) out[k] = String(d[k]);
++ return out;
++}
++
++/** The season for a date ('MM-DD' ranges, both days included, may run over New Year), or null. */
++function seasonOn(date, seasons) {
++ const d = date instanceof Date ? date : new Date(date || Date.now());
++ const md = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
++ return (seasons || []).find((s) => (s.from <= s.to ? md >= s.from && md <= s.to : md >= s.from || md <= s.to)) || null;
++}
++
++/** Readable text on a colour: near-black or white, whichever contrasts more (WCAG). null for a colour it cannot read. */
++function onColor(c) {
++ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || '').trim());
++ if (!m) return null;
++ const h = m[1].length === 3 ? m[1].split('').map((x) => x + x).join('') : m[1];
++ const lin = (i) => { const v = parseInt(h.slice(i, i + 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
++ const L = 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4);
++ return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#0b0d10' : '#ffffff';
++}
++
++/** Paint a theme's variables (only --custom-properties) on an element, <html> by default; returns the element. */
++function applyTheme(vars, { root } = {}) {
++ const el = root || (typeof document !== 'undefined' ? document.documentElement : null);
++ if (!el || !vars || typeof vars !== 'object') return el;
++ for (const [k, v] of Object.entries(vars)) if (k.startsWith('--') && v != null) el.style.setProperty(k, String(v));
++ if (vars['--accent'] && !vars['--on-accent']) { const on = onColor(vars['--accent']); if (on) el.style.setProperty('--on-accent', on); }
++ const scheme = vars['--color-scheme'];
++ if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }
++ return el;
++}
++
++/**
++ * The theme on screen now: { id, mode, tokens: { '--accent': '#…', … }, display: { motion, text, textScale, seasonal, calm },
++ * season: { id } | null, seasonMark } — from <html> and the loader (window.OpenVibeThemeLoader) when the page has it.
++ */
++function readTheme({ root, names = TOKENS } = {}) {
++ const el = root || (typeof document !== 'undefined' ? document.documentElement : null);
++ const L = typeof window !== 'undefined' ? window.OpenVibeThemeLoader : null;
++ const tokens = {};
++ if (el && typeof getComputedStyle === 'function') {
++ const cs = getComputedStyle(el);
++ for (const n of names) { const v = cs.getPropertyValue(n).trim(); if (v) tokens[n] = v; }
++ }
++ const raw = (L && L.display && typeof L.display.get === 'function') ? L.display.get() : {};
++ const display = { motion: 'auto', text: '100', seasonal: 'on', ...cleanDisplay(raw) };
++ let reduced = false;
++ try { reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { reduced = false; }
++ return {
++ id: el ? el.getAttribute('data-theme') : null,
++ mode: (el && el.getAttribute('data-theme-mode')) || tokens['--color-scheme'] || 'dark',
++ tokens,
++ display: { ...display, textScale: Number(display.text) / 100, calm: display.motion === 'reduced' || reduced },
++ season: el && el.getAttribute('data-ov-season') ? { id: el.getAttribute('data-ov-season') } : null,
++ seasonMark: el ? el.getAttribute('data-ov-season-mark') : null,
++ };
++}
++
++/** Call fn(readTheme()) now and on every theme, display or season change; returns stop(). */
++function followTheme(fn, opts = {}) {
++ if (typeof fn !== 'function') throw new TypeError('followTheme: pass a function');
++ if (typeof window === 'undefined' || !window.addEventListener) { fn(readTheme(opts)); return () => {}; }
++ let queued = false;
++ const fire = () => { if (queued) return; queued = true; Promise.resolve().then(() => { queued = false; fn(readTheme(opts)); }); };
++ const events = ['ov:theme', 'ov:display', 'ov:season'];
++ events.forEach((e) => window.addEventListener(e, fire));
++ const onStorage = (e) => { if (e.key === 'ov_theme' || e.key === 'ov_display') fire(); };
++ window.addEventListener('storage', onStorage);
++ let mo = null;
++ try {
++ mo = new MutationObserver(fire);
++ mo.observe(opts.root || document.documentElement, { attributes: true, attributeFilter: ['style', 'data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text'] });
++ } catch { mo = null; }
++ fn(readTheme(opts));
++ return () => { events.forEach((e) => window.removeEventListener(e, fire)); window.removeEventListener('storage', onStorage); if (mo) mo.disconnect(); };
++}
++
++module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
++},
++// ── 18: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2057,7 +2207,7 @@ function createChatClient(client, defaults = {}) {
module.exports = { createChatClient };
},
--// ── 18: src/ai.js ───────────────────────────────────────────────────
++// ── 19: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2132,7 +2282,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS };
},
--// ── 19: src/jobs.js ─────────────────────────────────────────────────
++// ── 20: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2170,7 +2320,7 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { appendFiles } = __load(20);
++const { appendFiles } = __load(21);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -2334,7 +2484,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 20: src/core/form.js ────────────────────────────────────────────
++// ── 21: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2368,7 +2518,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
},
--// ── 21: src/tools.js ────────────────────────────────────────────────
++// ── 22: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2402,8 +2552,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { isUpload, appendFiles } = __load(20);
--const { createJobsClient } = __load(19);
++const { isUpload, appendFiles } = __load(21);
++const { createJobsClient } = __load(20);
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@@ -2637,7 +2787,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 22: src/projects.js ─────────────────────────────────────────────
++// ── 23: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2753,7 +2903,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
},
--// ── 23: src/geo.js ──────────────────────────────────────────────────
++// ── 24: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2833,7 +2983,7 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, modules, realtime, media, community, bot,
-- search, chat, ai, jobs, tools, projects,
-- geo,
++ search, themes, chat, ai, jobs, tools,
++ projects, geo,
} = sdk;
export default sdk;
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 5a6b93b..e6d3dec 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -253,6 +253,18 @@
"validateTelemetrySample"
]
},
++ "themes": {
++ "object": [
++ "DISPLAY_OPTIONS",
++ "TOKENS",
++ "applyTheme",
++ "createThemesClient",
++ "followTheme",
++ "onColor",
++ "readTheme",
++ "seasonOn"
++ ]
++ },
"tips": {
"object": [
"createTipsClient"
@@@ -726,6 +738,22 @@
"pgAvailable": "function",
"valkeyAvailable": "function"
},
++ "openvibe-sdk/themes": {
++ "DISPLAY_OPTIONS": {
++ "object": [
++ "motion",
++ "seasonal",
++ "text"
++ ]
++ },
++ "TOKENS": "array",
++ "applyTheme": "function",
++ "createThemesClient": "function",
++ "followTheme": "function",
++ "onColor": "function",
++ "readTheme": "function",
++ "seasonOn": "function"
++ },
"openvibe-sdk/tips": {
"createTipsClient": "function"
},
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex a0556af..0f39f8c 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -6,11 +6,12 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
-- community, bot, search, chat, ai, jobs,
-- tools, projects, resources, vip, quest, live,
-- inventory, tips, blog, reviews, wiki, coupons,
-- deals, trade, space, host, news, food,
-- actor, run, video, work, zone, play,
-- download, openre, geo, usage, commerce, telemetry,
++ community, bot, search, themes, chat, ai,
++ jobs, tools, projects, resources, vip, quest,
++ live, inventory, tips, blog, reviews, wiki,
++ coupons, deals, trade, space, host, news,
++ food, actor, run, video, work, zone,
++ play, download, openre, geo, usage, commerce,
++ telemetry,
} = cjs;
export default cjs;
ddiff --git a/esm/themes.mjs b/esm/themes.mjs
nnew file mode 100644
iindex 0000000..f88c912
---- /dev/null
++++ b/esm/themes.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/themes.js. Do not edit.
++import cjs from '../src/themes.js';
++
++export const {
++ createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor,
++ DISPLAY_OPTIONS, TOKENS,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 30bfe0f..cc78615 100644
---- a/index.js
++++ b/index.js
@@@ -18,6 +18,7 @@ module.exports = {
community: require('./src/community'),
bot: require('./src/bot'),
search: require('./src/search'),
++ themes: require('./src/themes'),
chat: require('./src/chat'),
ai: require('./src/ai'),
jobs: require('./src/jobs'),
ddiff --git a/package.json b/package.json
iindex 1ce10e2..7c94a7a 100644
---- a/package.json
++++ b/package.json
@@@ -87,6 +87,11 @@
"import": "./esm/search.mjs",
"default": "./src/search.js"
},
++ "./themes": {
++ "types": "./types/themes.d.ts",
++ "import": "./esm/themes.mjs",
++ "default": "./src/themes.js"
++ },
"./limits": {
"types": "./types/limits.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 4e2fc37..a614d71 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -27,6 +27,7 @@ const ENTRIES = {
'community.mjs': '../src/community.js',
'bot.mjs': '../src/bot.js',
'search.mjs': '../src/search.js',
++ 'themes.mjs': '../src/themes.js',
'limits.mjs': '../src/limits.js',
'account-data.mjs': '../src/account-data.js',
'govern.mjs': '../src/govern.js',
ddiff --git a/src/themes.js b/src/themes.js
nnew file mode 100644
iindex 0000000..9c37fad
---- /dev/null
++++ b/src/themes.js
@@@ -0,0 +1,146 @@
++'use strict';
++/**
++ * openvibe-sdk/themes: the OpenVibe theme system for any app. A person's theme, display preferences (motion, text size,
++ * seasonal themes) and the seasons follow their account to every OpenVibe site; an app reads and changes them here, and
++ * a page or a game follows them as they change. Browser-safe.
++ *
++ * The account (OpenVibe.Network /api/themes):
++ *
++ * const themes = createThemesClient(client); // client from openvibe-sdk/core (the person's sign-in)
++ * const { themes: list } = await themes.catalog({ mode: 'dark' }); // approved themes; seasonal ones carry `season`
++ * const theme = await themes.get('nord'); // null when missing
++ * const mine = await themes.active(); // { theme_id, custom_variables, display } (guests: theme_id null)
++ * await themes.set('nord'); // or set('vibe', { custom: { '--accent': '#ff5c8a' } })
++ * await themes.setDisplay({ motion: 'reduced', text: '112', seasonal: 'off' });
++ * const { seasons, now } = await themes.seasons(); // what stands in for the default theme when
++ *
++ * On a page (the theme loader every OpenVibe site runs, openvibe-sdk/frame, paints the theme; this follows it):
++ *
++ * const stop = followTheme(({ tokens, mode, display, season }) => {
++ * game.setPalette(tokens['--accent'], tokens['--bg-primary']); // a canvas or WebGL scene keeps up
++ * game.setCalm(display.motion === 'reduced'); // no screen shake, fewer particles
++ * game.setUiScale(display.textScale); // 1, 1.12 or 1.25
++ * });
++ *
++ * followTheme calls back once at once and again on every change (another tab, the account's answer, a season starting,
++ * a display preference), with the CSS variables on <html> read fresh; stop() ends it. readTheme() is the same snapshot
++ * on demand. applyTheme(vars) paints a theme's variables on an element (default <html>) for apps without the loader.
++ */
++const { isOpenVibeError } = require('./core/errors');
++
++const enc = encodeURIComponent;
++const DISPLAY_OPTIONS = Object.freeze({ motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'] });
++/** The tokens every OpenVibe theme sets (the shared names apps style with). */
++const TOKENS = Object.freeze(['--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-card', '--bg-hover', '--text-primary', '--text-secondary', '--text-muted',
++ '--accent', '--accent-light', '--accent-dark', '--accent-strong', '--on-accent', '--on-accent-strong', '--accent-glow', '--border', '--border-light',
++ '--success', '--warning', '--danger', '--color-scheme']);
++
++function createThemesClient(client, { baseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createThemesClient: pass a client from openvibe-sdk/core');
++ const call = (opts, o = {}) => client.json({ service: 'network', baseUrl, ...opts, signal: o.signal });
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ const once = { idempotencyKey: false };
++ return {
++ /** query: mode (dark | light), search, sort (downloads | rating), limit → { themes } */
++ catalog: (query = {}, o = {}) => call({ path: '/api/themes', query }, o),
++ /** → the theme ({ id, name, slug, mode, variables, tags, … }) or null */
++ get: async (idOrSlug, o = {}) => { const d = await orNull(call({ path: `/api/themes/${enc(idOrSlug)}` }, o)); return d ? d.theme : null; },
++ /** An openvibe-theme@1 file (import it elsewhere, or submit an edited copy), or null */
++ export: (idOrSlug, o = {}) => orNull(call({ path: `/api/themes/${enc(idOrSlug)}/export` }, o)),
++ /** The person's theme: { theme_id, custom_variables, display } ({ theme_id: null, guest: true } signed out) */
++ active: (o = {}) => call({ path: '/api/themes/me/active' }, o),
++ /** Save the person's theme everywhere; custom: their own overrides of its variables (colours, lengths, shadows) */
++ set: (themeId, { custom = null } = {}, o = {}) => call({ method: 'PUT', path: '/api/themes/me', json: { theme_id: themeId, custom_variables: custom }, ...once }, o),
++ /** motion auto | reduced, text 100 | 112 | 125, seasonal on | off → { display } (unknown values are dropped) */
++ setDisplay: (patch = {}, o = {}) => call({ method: 'PUT', path: '/api/themes/me/display', json: cleanDisplay(patch), ...once }, o),
++ /** query: date (YYYY-MM-DD) → { seasons: [{ id, name, theme, from, to }], now } */
++ seasons: (query = {}, o = {}) => call({ path: '/api/themes/seasons', query }, o),
++ /** The person's own submissions, with their review status */
++ submissions: (o = {}) => call({ path: '/api/themes/me/submissions' }, o),
++ /** Submit a theme ({ name, description?, mode, variables, tags? }): private to its author until approved */
++ submit: (theme, o = {}) => call({ method: 'POST', path: '/api/themes', json: theme, ...once }, o),
++ /** Submit an openvibe-theme@1 file */
++ import: (file, o = {}) => call({ method: 'POST', path: '/api/themes/import', json: file, ...once }, o),
++ };
++}
++
++function cleanDisplay(d = {}) {
++ const out = {};
++ for (const k of Object.keys(DISPLAY_OPTIONS)) if (d && DISPLAY_OPTIONS[k].includes(String(d[k]))) out[k] = String(d[k]);
++ return out;
++}
++
++/** The season for a date ('MM-DD' ranges, both days included, may run over New Year), or null. */
++function seasonOn(date, seasons) {
++ const d = date instanceof Date ? date : new Date(date || Date.now());
++ const md = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
++ return (seasons || []).find((s) => (s.from <= s.to ? md >= s.from && md <= s.to : md >= s.from || md <= s.to)) || null;
++}
++
++/** Readable text on a colour: near-black or white, whichever contrasts more (WCAG). null for a colour it cannot read. */
++function onColor(c) {
++ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || '').trim());
++ if (!m) return null;
++ const h = m[1].length === 3 ? m[1].split('').map((x) => x + x).join('') : m[1];
++ const lin = (i) => { const v = parseInt(h.slice(i, i + 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
++ const L = 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4);
++ return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#0b0d10' : '#ffffff';
++}
++
++/** Paint a theme's variables (only --custom-properties) on an element, <html> by default; returns the element. */
++function applyTheme(vars, { root } = {}) {
++ const el = root || (typeof document !== 'undefined' ? document.documentElement : null);
++ if (!el || !vars || typeof vars !== 'object') return el;
++ for (const [k, v] of Object.entries(vars)) if (k.startsWith('--') && v != null) el.style.setProperty(k, String(v));
++ if (vars['--accent'] && !vars['--on-accent']) { const on = onColor(vars['--accent']); if (on) el.style.setProperty('--on-accent', on); }
++ const scheme = vars['--color-scheme'];
++ if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }
++ return el;
++}
++
++/**
++ * The theme on screen now: { id, mode, tokens: { '--accent': '#…', … }, display: { motion, text, textScale, seasonal, calm },
++ * season: { id } | null, seasonMark } — from <html> and the loader (window.OpenVibeThemeLoader) when the page has it.
++ */
++function readTheme({ root, names = TOKENS } = {}) {
++ const el = root || (typeof document !== 'undefined' ? document.documentElement : null);
++ const L = typeof window !== 'undefined' ? window.OpenVibeThemeLoader : null;
++ const tokens = {};
++ if (el && typeof getComputedStyle === 'function') {
++ const cs = getComputedStyle(el);
++ for (const n of names) { const v = cs.getPropertyValue(n).trim(); if (v) tokens[n] = v; }
++ }
++ const raw = (L && L.display && typeof L.display.get === 'function') ? L.display.get() : {};
++ const display = { motion: 'auto', text: '100', seasonal: 'on', ...cleanDisplay(raw) };
++ let reduced = false;
++ try { reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { reduced = false; }
++ return {
++ id: el ? el.getAttribute('data-theme') : null,
++ mode: (el && el.getAttribute('data-theme-mode')) || tokens['--color-scheme'] || 'dark',
++ tokens,
++ display: { ...display, textScale: Number(display.text) / 100, calm: display.motion === 'reduced' || reduced },
++ season: el && el.getAttribute('data-ov-season') ? { id: el.getAttribute('data-ov-season') } : null,
++ seasonMark: el ? el.getAttribute('data-ov-season-mark') : null,
++ };
++}
++
++/** Call fn(readTheme()) now and on every theme, display or season change; returns stop(). */
++function followTheme(fn, opts = {}) {
++ if (typeof fn !== 'function') throw new TypeError('followTheme: pass a function');
++ if (typeof window === 'undefined' || !window.addEventListener) { fn(readTheme(opts)); return () => {}; }
++ let queued = false;
++ const fire = () => { if (queued) return; queued = true; Promise.resolve().then(() => { queued = false; fn(readTheme(opts)); }); };
++ const events = ['ov:theme', 'ov:display', 'ov:season'];
++ events.forEach((e) => window.addEventListener(e, fire));
++ const onStorage = (e) => { if (e.key === 'ov_theme' || e.key === 'ov_display') fire(); };
++ window.addEventListener('storage', onStorage);
++ let mo = null;
++ try {
++ mo = new MutationObserver(fire);
++ mo.observe(opts.root || document.documentElement, { attributes: true, attributeFilter: ['style', 'data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text'] });
++ } catch { mo = null; }
++ fn(readTheme(opts));
++ return () => { events.forEach((e) => window.removeEventListener(e, fire)); window.removeEventListener('storage', onStorage); if (mo) mo.disconnect(); };
++}
++
++module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 710e15c..5f3d89d 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 67);
++ assert.equal(subpaths.length, 68);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/themes.test.js b/test/themes.test.js
nnew file mode 100644
iindex 0000000..7aaeb01
---- /dev/null
++++ b/test/themes.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++/**
++ * Themes: the account's theme API (paths, bodies, a missing theme is null, display values cleaned), seasons by date
++ * (over New Year too), readable text on a colour, painting a theme, and followTheme keeping a page or game in step.
++ */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const themes = require('../src/themes');
++
++run([
++ ['the account: paths, bodies, null for a missing theme, display cleaned', async () => {
++ const srv = await stubServer((req, res) => {
++ if (req.url === '/api/themes/nope') return send(res, 404, { error: 'Theme not found' });
++ if (req.url === '/api/themes/nord') return send(res, 200, { theme: { id: 'nord', variables: { '--accent': '#88c0d0' } } });
++ return send(res, 200, { ok: true });
++ });
++ const t = themes.createThemesClient(createClient({ baseUrls: { network: srv.url }, token: 'user-jwt' }));
++ assert.equal(await t.get('nope'), null);
++ assert.equal((await t.get('nord')).id, 'nord');
++ await t.catalog({ mode: 'dark' });
++ await t.active();
++ await t.set('vibe', { custom: { '--accent': '#ff5c8a' } });
++ await t.setDisplay({ motion: 'reduced', text: '125', seasonal: 'off', colour: 'red', 'text-extra': 1 });
++ await t.seasons({ date: '2026-10-25' });
++ const got = srv.requests.slice(2).map((r) => [r.method, r.url, r.body && String(r.body).length ? JSON.parse(String(r.body)) : null]);
++ assert.deepEqual(got, [
++ ['GET', '/api/themes?mode=dark', null],
++ ['GET', '/api/themes/me/active', null],
++ ['PUT', '/api/themes/me', { theme_id: 'vibe', custom_variables: { '--accent': '#ff5c8a' } }],
++ ['PUT', '/api/themes/me/display', { motion: 'reduced', text: '125', seasonal: 'off' }],
++ ['GET', '/api/themes/seasons?date=2026-10-25', null],
++ ]);
++ assert.equal(srv.requests[0].headers.authorization, 'Bearer user-jwt');
++ await srv.close();
++ }],
++ ['seasons by date, readable text, painting a theme', async () => {
++ const seasons = [{ id: 'halloween', theme: 'halloween', from: '10-01', to: '10-31' }, { id: 'ny', theme: 'confetti', from: '12-30', to: '01-02' }];
++ assert.equal(themes.seasonOn(new Date(2026, 9, 31), seasons).id, 'halloween');
++ assert.equal(themes.seasonOn(new Date(2026, 10, 1), seasons), null);
++ assert.equal(themes.seasonOn(new Date(2027, 0, 2), seasons).id, 'ny', 'a season may run over New Year');
++ assert.equal(themes.onColor('#ff7a1a'), '#0b0d10');
++ assert.equal(themes.onColor('#1d4ed8'), '#ffffff');
++ assert.equal(themes.onColor('orange'), null);
++ const props = {}; const attrs = {};
++ const el = { style: { setProperty: (k, v) => { props[k] = v; } }, setAttribute: (k, v) => { attrs[k] = v; } };
++ themes.applyTheme({ '--accent': '#ff7a1a', '--color-scheme': 'dark', color: 'red' }, { root: el });
++ assert.deepEqual(props, { '--accent': '#ff7a1a', '--color-scheme': 'dark', '--on-accent': '#0b0d10' }, 'only custom properties, with readable text on the accent');
++ assert.equal(attrs['data-theme-mode'], 'dark');
++ }],
++ ['followTheme: once at once, again on each change (batched), stop() ends it', async () => {
++ const listeners = {};
++ const attrs = { 'data-theme': 'halloween', 'data-theme-mode': 'dark', 'data-ov-season': 'halloween', 'data-ov-season-mark': 'halloween' };
++ let accent = '#ff7a1a';
++ const html = { getAttribute: (k) => attrs[k] ?? null };
++ const saved = { window: global.window, document: global.document, getComputedStyle: global.getComputedStyle, MutationObserver: global.MutationObserver };
++ global.document = { documentElement: html };
++ global.getComputedStyle = () => ({ getPropertyValue: (n) => (n === '--accent' ? accent : n === '--bg-primary' ? '#0e0a14' : '') });
++ global.MutationObserver = class { constructor(fn) { this.fn = fn; } observe() {} disconnect() { this.off = true; } };
++ global.window = {
++ addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
++ removeEventListener: (t, f) => { listeners[t] = (listeners[t] || []).filter((x) => x !== f); },
++ OpenVibeThemeLoader: { display: { get: () => ({ motion: 'reduced', text: '112', seasonal: 'on' }) } },
++ };
++ try {
++ const seen = [];
++ const stop = themes.followTheme((t) => seen.push(t));
++ assert.equal(seen.length, 1, 'called at once');
++ assert.deepEqual([seen[0].id, seen[0].mode, seen[0].tokens['--accent'], seen[0].tokens['--bg-primary'], seen[0].season.id, seen[0].seasonMark], ['halloween', 'dark', '#ff7a1a', '#0e0a14', 'halloween', 'halloween']);
++ assert.deepEqual([seen[0].display.textScale, seen[0].display.calm], [1.12, true], 'text scale and calm motion for a game');
++ accent = '#88c0d0';
++ listeners['ov:theme'].forEach((f) => f()); listeners['ov:display'].forEach((f) => f());
++ await new Promise((r) => setImmediate(r));
++ assert.equal(seen.length, 2, 'two events in one tick: one call');
++ assert.equal(seen[1].tokens['--accent'], '#88c0d0', 'read fresh');
++ listeners.storage.forEach((f) => f({ key: 'unrelated' }));
++ await new Promise((r) => setImmediate(r));
++ assert.equal(seen.length, 2, 'other storage keys are ignored');
++ stop();
++ assert.ok(!(listeners['ov:theme'] || []).length && !(listeners.storage || []).length, 'stop() removes every listener');
++ } finally { Object.assign(global, saved); }
++ }],
++]);
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 1904d3c..48bd714 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -7,6 +7,7 @@ import * as media from './media';
import * as community from './community';
import * as bot from './bot';
import * as search from './search';
++import * as themes from './themes';
import * as chat from './chat';
import * as ai from './ai';
import * as jobs from './jobs';
@@@ -24,6 +25,7 @@ export * as media from './media';
export * as community from './community';
export * as bot from './bot';
export * as search from './search';
++export * as themes from './themes';
export * as chat from './chat';
export * as ai from './ai';
export * as jobs from './jobs';
@@@ -33,6 +35,6 @@ export * as geo from './geo';
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
++ media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; themes: typeof themes; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
};
export default sdk;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex f875fcf..2f1fdbd 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -9,6 +9,7 @@ export * as media from './media';
export * as community from './community';
export * as bot from './bot';
export * as search from './search';
++export * as themes from './themes';
export * as chat from './chat';
export * as ai from './ai';
export * as jobs from './jobs';
ddiff --git a/types/themes.d.ts b/types/themes.d.ts
nnew file mode 100644
iindex 0000000..702b755
---- /dev/null
++++ b/types/themes.d.ts
@@@ -0,0 +1,42 @@
++import type { OpenVibeClient } from './core';
++
++type Opts = { signal?: AbortSignal };
++export type ThemeVariables = Record<string, string>;
++export interface Season { id: string; name: string; theme: string; from: string; to: string }
++export interface Theme {
++ id: string; name: string; slug: string; description?: string; mode: 'dark' | 'light'; variables: ThemeVariables;
++ preview_colors?: string[] | null; tags?: string[]; is_builtin?: number; downloads?: number;
++ /** A seasonal theme: when it stands in for the default theme (MM-DD, both days included). */
++ season?: { id: string; name: string; from: string; to: string };
++ [field: string]: unknown;
++}
++export interface DisplayPrefs { motion?: 'auto' | 'reduced'; text?: '100' | '112' | '125'; seasonal?: 'on' | 'off' }
++export interface ActiveTheme { theme_id: string | null; custom_variables?: ThemeVariables | null; display?: DisplayPrefs | null; guest?: boolean }
++export interface ThemesClient {
++ catalog(query?: { mode?: 'dark' | 'light'; search?: string; sort?: 'downloads' | 'rating'; limit?: number }, opts?: Opts): Promise<{ themes: Theme[] }>;
++ get(idOrSlug: string, opts?: Opts): Promise<Theme | null>;
++ export(idOrSlug: string, opts?: Opts): Promise<{ format: 'openvibe-theme@1'; name: string; slug: string; mode: string; variables: ThemeVariables; [k: string]: unknown } | null>;
++ active(opts?: Opts): Promise<ActiveTheme>;
++ set(themeId: string, extra?: { custom?: ThemeVariables | null }, opts?: Opts): Promise<{ success: boolean; theme_id: string; custom_variables: ThemeVariables | null }>;
++ setDisplay(patch: DisplayPrefs, opts?: Opts): Promise<{ ok: boolean; display: DisplayPrefs }>;
++ seasons(query?: { date?: string }, opts?: Opts): Promise<{ seasons: Season[]; now: { id: string; name: string; theme: string } | null }>;
++ submissions(opts?: Opts): Promise<{ themes: Theme[] }>;
++ submit(theme: { name: string; description?: string; mode: 'dark' | 'light'; variables: ThemeVariables; tags?: string[] }, opts?: Opts): Promise<Record<string, unknown>>;
++ import(file: Record<string, unknown>, opts?: Opts): Promise<Record<string, unknown>>;
++}
++export interface ThemeSnapshot {
++ id: string | null;
++ mode: 'dark' | 'light' | string;
++ tokens: Record<string, string>;
++ display: { motion: 'auto' | 'reduced'; text: '100' | '112' | '125'; seasonal: 'on' | 'off'; textScale: number; calm: boolean };
++ season: { id: string } | null;
++ seasonMark: string | null;
++}
++export function createThemesClient(client: OpenVibeClient, opts?: { baseUrl?: string }): ThemesClient;
++export function followTheme(fn: (theme: ThemeSnapshot) => void, opts?: { root?: Element; names?: readonly string[] }): () => void;
++export function readTheme(opts?: { root?: Element; names?: readonly string[] }): ThemeSnapshot;
++export function applyTheme(vars: ThemeVariables, opts?: { root?: HTMLElement }): HTMLElement | null;
++export function seasonOn(date: Date | string | number | null | undefined, seasons: Season[]): Season | null;
++export function onColor(hex: string): '#0b0d10' | '#ffffff' | null;
++export const DISPLAY_OPTIONS: Readonly<{ motion: string[]; text: string[]; seasonal: string[] }>;
++export const TOKENS: readonly string[];