openvibers/openvibe.sdk Commits
openvibe-sdk/themes: the account's theme, display preferences and seasons (createThemesClient), and followTheme for pages, canvases and games (#80)
Changes
@@@ -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 |@@@ -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'),@@@ -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 = 'openmodule.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 = 'opemodule.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;@@@ -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"},@@@ -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;nnew file mode 100644@@@ -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;@@@ -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'),@@@ -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,@@@ -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',nnew file mode 100644@@@ -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 };@@@ -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 });nnew file mode 100644@@@ -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); }++ }],++]);@@@ -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;@@@ -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';nnew file mode 100644@@@ -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[];
@@ -173,6 +173,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -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/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/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/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/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/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 |
| `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 |
@@ -15,6 +15,7 @@ module.exports = {
@@ -15,6 +15,7 @@ module.exports = {
community: require('./src/community'),
community: require('./src/community'),
bot: require('./src/bot'),
bot: require('./src/bot'),
search: require('./src/search'),
search: require('./src/search'),
+ themes: require('./src/themes'),
chat: require('./src/chat'),
chat: require('./src/chat'),
ai: require('./src/ai'),
ai: require('./src/ai'),
jobs: require('./src/jobs'),
jobs: require('./src/jobs'),
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.64.0, MIT). Do not edit.
// 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
// 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 */
/* eslint-disable */
const __defs = [
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
// ── 0: browser.js ──────────────────────────────────────────────────
@@ -22,12 +22,13 @@ module.exports = {
@@ -22,12 +22,13 @@ module.exports = {
community: __load(14),
community: __load(14),
bot: __load(15),
bot: __load(15),
search: __load(16),
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 ───────────────────────────────────────────
// ── 1: src/core/index.js ───────────────────────────────────────────
@@ -1979,7 +1980,156 @@ function createSearchClient(client, defaults = {}) {
@@ -1979,7 +1980,156 @@ function createSearchClient(client, defaults = {}) {
module.exports = { createSearchClient, queryOf };
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) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2057,7 +2207,7 @@ function createChatClient(client, defaults = {}) {
@@ -2057,7 +2207,7 @@ function createChatClient(client, defaults = {}) {
module.exports = { createChatClient };
module.exports = { createChatClient };
},
},
-// ── 18: src/ai.js ───────────────────────────────────────────────────
+// ── 19: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2132,7 +2282,7 @@ function createAiClient(client, defaults = {}) {
@@ -2132,7 +2282,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS };
module.exports = { createAiClient, TERMINAL, OPS };
},
},
-// ── 19: src/jobs.js ─────────────────────────────────────────────────
+// ── 20: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2170,7 +2320,7 @@ function (module, exports) {
@@ -2170,7 +2320,7 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
const { newIdempotencyKey } = __load(5);
-const { appendFiles } = __load(20);
+const { appendFiles } = __load(21);
const { parseSSE } = __load(11);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@ -2334,7 +2484,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
@@ -2334,7 +2484,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
},
-// ── 20: src/core/form.js ────────────────────────────────────────────
+// ── 21: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2368,7 +2518,7 @@ function appendFiles(form, list, where) {
@@ -2368,7 +2518,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
module.exports = { isBlob, isUpload, appendFiles };
},
},
-// ── 21: src/tools.js ────────────────────────────────────────────────
+// ── 22: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2402,8 +2552,8 @@ function (module, exports) {
@@ -2402,8 +2552,8 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
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 enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@ -2637,7 +2787,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
@@ -2637,7 +2787,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
},
-// ── 22: src/projects.js ─────────────────────────────────────────────
+// ── 23: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2753,7 +2903,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
@@ -2753,7 +2903,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
module.exports = { createProjectsClient };
},
},
-// ── 23: src/geo.js ──────────────────────────────────────────────────
+// ── 24: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2833,7 +2983,7 @@ export const {
@@ -2833,7 +2983,7 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, modules, realtime, media, community, bot,
registry, modules, realtime, media, community, bot,
- search, chat, ai, jobs, tools, projects,
- geo,
+ search, themes, chat, ai, jobs, tools,
+ projects, geo,
} = sdk;
} = sdk;
export default sdk;
export default sdk;
@@ -253,6 +253,18 @@
@@ -253,6 +253,18 @@
"validateTelemetrySample"
"validateTelemetrySample"
]
]
},
},
+ "themes": {
+ "object": [
+ "DISPLAY_OPTIONS",
+ "TOKENS",
+ "applyTheme",
+ "createThemesClient",
+ "followTheme",
+ "onColor",
+ "readTheme",
+ "seasonOn"
+ ]
+ },
"tips": {
"tips": {
"object": [
"object": [
"createTipsClient"
"createTipsClient"
@@ -726,6 +738,22 @@
@@ -726,6 +738,22 @@
"pgAvailable": "function",
"pgAvailable": "function",
"valkeyAvailable": "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": {
"openvibe-sdk/tips": {
"createTipsClient": "function"
"createTipsClient": "function"
},
},
@@ -6,11 +6,12 @@ export const {
@@ -6,11 +6,12 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
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;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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;
@@ -18,6 +18,7 @@ module.exports = {
@@ -18,6 +18,7 @@ module.exports = {
community: require('./src/community'),
community: require('./src/community'),
bot: require('./src/bot'),
bot: require('./src/bot'),
search: require('./src/search'),
search: require('./src/search'),
+ themes: require('./src/themes'),
chat: require('./src/chat'),
chat: require('./src/chat'),
ai: require('./src/ai'),
ai: require('./src/ai'),
jobs: require('./src/jobs'),
jobs: require('./src/jobs'),
@@ -87,6 +87,11 @@
@@ -87,6 +87,11 @@
"import": "./esm/search.mjs",
"import": "./esm/search.mjs",
"default": "./src/search.js"
"default": "./src/search.js"
},
},
+ "./themes": {
+ "types": "./types/themes.d.ts",
+ "import": "./esm/themes.mjs",
+ "default": "./src/themes.js"
+ },
"./limits": {
"./limits": {
"types": "./types/limits.d.ts",
"types": "./types/limits.d.ts",
"browser": null,
"browser": null,
@@ -27,6 +27,7 @@ const ENTRIES = {
@@ -27,6 +27,7 @@ const ENTRIES = {
'community.mjs': '../src/community.js',
'community.mjs': '../src/community.js',
'bot.mjs': '../src/bot.js',
'bot.mjs': '../src/bot.js',
'search.mjs': '../src/search.js',
'search.mjs': '../src/search.js',
+ 'themes.mjs': '../src/themes.js',
'limits.mjs': '../src/limits.js',
'limits.mjs': '../src/limits.js',
'account-data.mjs': '../src/account-data.js',
'account-data.mjs': '../src/account-data.js',
'govern.mjs': '../src/govern.js',
'govern.mjs': '../src/govern.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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 };
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
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.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');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -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); }
+ }],
+]);
@@ -7,6 +7,7 @@ import * as media from './media';
@@ -7,6 +7,7 @@ import * as media from './media';
import * as community from './community';
import * as community from './community';
import * as bot from './bot';
import * as bot from './bot';
import * as search from './search';
import * as search from './search';
+import * as themes from './themes';
import * as chat from './chat';
import * as chat from './chat';
import * as ai from './ai';
import * as ai from './ai';
import * as jobs from './jobs';
import * as jobs from './jobs';
@@ -24,6 +25,7 @@ export * as media from './media';
@@ -24,6 +25,7 @@ export * as media from './media';
export * as community from './community';
export * as community from './community';
export * as bot from './bot';
export * as bot from './bot';
export * as search from './search';
export * as search from './search';
+export * as themes from './themes';
export * as chat from './chat';
export * as chat from './chat';
export * as ai from './ai';
export * as ai from './ai';
export * as jobs from './jobs';
export * as jobs from './jobs';
@@ -33,6 +35,6 @@ export * as geo from './geo';
@@ -33,6 +35,6 @@ export * as geo from './geo';
declare const sdk: typeof core & {
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
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;
export default sdk;
@@ -9,6 +9,7 @@ export * as media from './media';
@@ -9,6 +9,7 @@ export * as media from './media';
export * as community from './community';
export * as community from './community';
export * as bot from './bot';
export * as bot from './bot';
export * as search from './search';
export * as search from './search';
+export * as themes from './themes';
export * as chat from './chat';
export * as chat from './chat';
export * as ai from './ai';
export * as ai from './ai';
export * as jobs from './jobs';
export * as jobs from './jobs';
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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[];