openvibers/openvibe.shared Commits

4.45.0: comfort settings beside the theme (#185)

OP OpenVibers committed 14 days ago · 8288a3d38f · 8 files, +125 −10

More contrast, a readable font, colour-blind-safe status colours and OLED black
are display preferences in theme-loader.js beside motion, text, seasonal and
scheme: one attribute on <html> each, !important custom properties over the
inline theme, nothing fetched. They follow the account like the others
(OpenVibe.Network keeps the keys; OpenVibe.Design sets them).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0a2a626..df0ea93 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,22 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.45.0 — 2026-10-29
++
++- **Comfort settings beside the theme.** Four display preferences join motion, text size, seasonal and scheme in
++ theme-loader.js (`OpenVibeThemeLoader.display`), and follow the account the same way:
++ - `contrast: 'more'`: secondary and muted text and the borders move toward the text colour, links in running text are
++ underlined, and focus rings are thicker;
++ - `font: 'readable'`: Atkinson Hyperlegible where it is installed, else Verdana or Tahoma, with a little more letter,
++ word and line spacing;
++ - `colors: 'cb-safe'`: success, warning and danger in Okabe-Ito blue, yellow and vermillion (with a light half), so
++ red and green never carry a meaning alone;
++ - `oled: 'on'`: true black backgrounds on dark themes.
++ Each sets one attribute on `<html>` (`data-ov-contrast`, `data-ov-font`, `data-ov-colors`, `data-ov-oled`). The rules are
++ `!important` custom properties, because the theme's own values are inline. Nothing is fetched (no font file). They
++ change how any theme reads, never which theme it is. OpenVibe.Design's "Your look" sets them; OpenVibe.Network keeps
++ them on the account.
++
## 4.44.0 — 2026-10-29
- **`showcase.fold({ id, title, hint, html, open })`** folds read-once material (how it works, the code samples, what
ddiff --git a/README.md b/README.md
iindex 43e7b7f..ec4458a 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.44.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.45.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 344daf1..9a6bf88 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.44.0",
++ "version": "4.45.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/docs/tokens.md b/docs/tokens.md
iindex 04b94f6..b89f96b 100644
---- a/docs/tokens.md
++++ b/docs/tokens.md
@@@ -73,6 +73,22 @@ Status: the generated Kotlin, Swift and Dart are checked against the Compose Mat
review and by the tests' expected lines. This host has no Kotlin, Swift or Dart toolchain to compile them; a native
CI job is the next step.
++## Comfort settings (4.45.0)
++
++A theme says what the network looks like; comfort settings say how it should read for this person, on every site and
++on top of any theme. They are display preferences (`OpenVibeThemeLoader.display.set({ contrast: 'more' })`), saved on the
++device and on the account:
++
++| Setting | Values | What changes |
++|---|---|---|
++| `contrast` | `normal`, `more` | secondary and muted text and the borders toward the text colour; links in running text underlined; thicker focus rings |
++| `font` | `default`, `readable` | Atkinson Hyperlegible or Verdana or Tahoma, with more letter, word and line spacing |
++| `colors` | `default`, `cb-safe` | success, warning and danger in Okabe-Ito blue, yellow and vermillion (light and dark halves) |
++| `oled` | `off`, `on` | true black backgrounds when the theme is dark |
++
++A native app reads the same object from `GET /api/themes/me/active` (`display`) and applies the same rules to its own
++tokens: the settings are data, not CSS.
++
## Why this shape (native apps, not web views)
The owner, 2026-10-29: "translate it to official and optimized Android and iOS apps in the future that aren't just web
ddiff --git a/package-lock.json b/package-lock.json
iindex 0fe8bfa..1d7db21 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.44.0",
++ "version": "4.45.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.44.0",
++ "version": "4.45.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 1dc7b9a..24ac519 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.44.0",
++ "version": "4.45.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/display-comfort.test.js b/test/display-comfort.test.js
nnew file mode 100644
iindex 0000000..6e511b8
---- /dev/null
++++ b/test/display-comfort.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++// Comfort settings (theme-loader.js display, 4.45.0): more contrast, a readable font, colour-blind-safe status colours
++// and OLED black are display preferences like motion and text. Each sets one attribute on <html>, an unknown value is
++// ignored, the defaults set none, and the account's settings (from /api/themes/me/active) are applied in place.
++// theme-loader.js runs in a small fake browser, as in theme-try.test.js.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');
++
++function load({ store = new Map(), active = null } = {}) {
++ const attrs = new Map(); const props = new Map(); const puts = [];
++ const html = { style: { setProperty: (k, v) => props.set(k, v), removeProperty: (k) => props.delete(k) }, setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k), attrs };
++ let head = null;
++ const sandbox = {
++ JSON, Object, String, Number, Math, Array, Promise, Error, URL, URLSearchParams, Date, setTimeout() {}, clearTimeout() {},
++ document: { documentElement: html, readyState: 'complete', cookie: '', get head() { return head; }, body: null, querySelector: () => null, getElementById: () => null, createElement: () => ({}), referrer: '', visibilityState: 'visible', addEventListener() {} },
++ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
++ sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
++ location: { hostname: 'openvibe.live', protocol: 'https:', search: '', href: 'https://openvibe.live/' },
++ history: { state: null, replaceState() {} },
++ getComputedStyle: () => ({ getPropertyValue: () => '' }),
++ matchMedia: () => ({ matches: false, addEventListener() {} }),
++ fetch: (url, o) => {
++ if (o && o.method === 'PUT') { puts.push({ url, body: JSON.parse(o.body) }); return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); }
++ return Promise.resolve({ ok: !!active, json: () => Promise.resolve(active) });
++ },
++ CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
++ addEventListener() {}, dispatchEvent() {},
++ };
++ sandbox.window = sandbox;
++ sandbox.globalThis = sandbox;
++ vm.runInNewContext(SRC, sandbox);
++ return { L: sandbox.OpenVibeThemeLoader, html, puts, store };
++}
++
++// 1. The defaults set no comfort attribute.
++let p = load();
++const d = p.L.display.get();
++assert.deepStrictEqual({ contrast: d.contrast, font: d.font, colors: d.colors, oled: d.oled }, { contrast: 'normal', font: 'default', colors: 'default', oled: 'off' });
++for (const a of ['data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled']) assert.strictEqual(p.html.getAttribute(a), null, `${a} is not set by default`);
++assert.strictEqual(JSON.stringify(p.L.display.options.contrast), '["normal","more"]', 'the options are listed (another realm, so compared as JSON)');
++
++// 2. Each setting sets its attribute, saves locally and, offline, does not need the account.
++p.L.display.set({ contrast: 'more', font: 'readable', colors: 'cb-safe', oled: 'on' }, { local: true });
++assert.strictEqual(p.html.getAttribute('data-ov-contrast'), 'more');
++assert.strictEqual(p.html.getAttribute('data-ov-font'), 'readable');
++assert.strictEqual(p.html.getAttribute('data-ov-colors'), 'cb-safe');
++assert.strictEqual(p.html.getAttribute('data-ov-oled'), 'on');
++assert.strictEqual(JSON.parse(p.store.get('ov_display')).font, 'readable', 'saved with the other display settings');
++
++// 3. Back to the default removes the attribute; an unknown value is ignored.
++p.L.display.set({ contrast: 'normal', font: 'comic-sans', oled: 'off' }, { local: true });
++assert.strictEqual(p.html.getAttribute('data-ov-contrast'), null);
++assert.strictEqual(p.html.getAttribute('data-ov-font'), 'readable', 'an unknown font is not a change');
++assert.strictEqual(p.html.getAttribute('data-ov-oled'), null);
++
++// 4. A saved setting paints on the next page before anything else loads.
++p = load({ store: new Map([['ov_display', JSON.stringify({ contrast: 'more', colors: 'cb-safe' })]]) });
++assert.strictEqual(p.html.getAttribute('data-ov-contrast'), 'more');
++assert.strictEqual(p.html.getAttribute('data-ov-colors'), 'cb-safe');
++
++// 5. The CSS: every rule is behind its attribute and wins over the theme's inline values.
++const css = (SRC.match(/const COMFORT_CSS = ([\s\S]*?);\n/) || [])[1] || '';
++assert.ok(/html\[data-ov-contrast=more\]\{--text-secondary:[^}]*!important/.test(css), 'contrast overrides the inline theme');
++assert.ok(/html\[data-ov-oled=on\]\[data-theme-mode=dark\]\{--bg-primary:#000!important/.test(css), 'OLED black only on a dark theme');
++assert.ok(/html\[data-ov-colors=cb-safe\]\[data-theme-mode=light\]/.test(css), 'colour-blind-safe colours have a light half');
++assert.ok(!/url\(/.test(css), 'no font or image is fetched');
++
++console.log('display comfort: defaults, set, unknown values, saved paint, CSS behind attributes');
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 5485c7a..53ac2a3 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -389,15 +389,25 @@
// ── Auto-apply on load (synchronous, prevents FOUC) ─────
// ── Display preferences: motion + text size, one setting for every OpenVibe site ─────────────
-- // OpenVibeThemeLoader.display.get() → { motion: 'auto'|'reduced', text: '100'|'112'|'125' }
++ // OpenVibeThemeLoader.display.get() → { motion: 'auto'|'reduced', text: '100'|'112'|'125', seasonal, scheme,
++ // contrast: 'normal'|'more', font: 'default'|'readable', colors: 'default'|'cb-safe', oled: 'off'|'on' }
// OpenVibeThemeLoader.display.set({ motion, text }) applies here, in other tabs, and saves to the account
// Applied as <html data-ov-motion> and --ov-text-scale before first paint; the shared stylesheet
// below makes every shared component honour them, and sites can key their own CSS off the same hooks.
// seasonal: 'on'|'off' the season's theme in place of the default theme (see Seasons above)
// scheme: 'manual'|'auto' the picked theme as it is, or its half that matches the device (see Light, dark and Auto)
-- const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'] };
++ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'] };
++ // Comfort (4.45.0): an attribute on <html> each; !important because the theme is inline (applyVars). contrast=more,
++ // font=readable (Atkinson Hyperlegible/Verdana), colors=cb-safe (Okabe-Ito), oled=on (black on dark themes).
++ const COMFORT_CSS = 'html[data-ov-contrast=more]{--text-secondary:color-mix(in srgb,var(--text-primary) 88%,var(--bg-primary))!important;--text-muted:color-mix(in srgb,var(--text-primary) 76%,var(--bg-primary))!important;--border:color-mix(in srgb,var(--text-primary) 40%,var(--bg-primary))!important;--border-light:color-mix(in srgb,var(--text-primary) 55%,var(--bg-primary))!important}'
++ + 'html[data-ov-contrast=more] :is(p,dd,td) a:not([class]){text-decoration-line:underline;text-underline-offset:2px}html[data-ov-contrast=more] :focus-visible{outline:3px solid var(--accent-strong,var(--accent))!important;outline-offset:2px}'
++ + 'html[data-ov-font=readable]{--ov-font:"Atkinson Hyperlegible",Verdana,Tahoma,"Segoe UI",system-ui,sans-serif!important}html[data-ov-font=readable] body{font-family:var(--ov-font)!important;letter-spacing:.02em;word-spacing:.06em}html[data-ov-font=readable] :is(p,li,dd){line-height:1.7}'
++ + 'html[data-ov-colors=cb-safe]{--success:#56b4e9!important;--success-text:#7cc6ef!important;--warning:#f0e442!important;--warning-text:#f0e442!important;--danger:#e8743b!important;--danger-text:#f0985f!important}'
++ + 'html[data-ov-colors=cb-safe][data-theme-mode=light]{--success:#0072b2!important;--success-text:#005a8c!important;--warning:#e69f00!important;--warning-text:#8a6100!important;--danger:#d55e00!important;--danger-text:#a84400!important}'
++ + 'html[data-ov-oled=on][data-theme-mode=dark]{--bg-primary:#000!important;--bg-secondary:#050608!important;--bg-tertiary:#0b0d12!important;--bg-card:#0a0c10!important;--bg-input:#0b0d12!important;--bg-hover:#12151c!important}';
++ const COMFORT_ATTRS = { contrast: ['data-ov-contrast', 'normal'], font: ['data-ov-font', 'default'], colors: ['data-ov-colors', 'default'], oled: ['data-ov-oled', 'off'] };
function readDisplay() {
-- const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual' };
++ const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off' };
try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }
return out;
}
@@@ -406,9 +416,10 @@
const el = document.documentElement;
if (d.motion === 'reduced') el.setAttribute('data-ov-motion', 'reduced'); else el.removeAttribute('data-ov-motion');
if (d.text === '100') { el.style.removeProperty('--ov-text-scale'); el.removeAttribute('data-ov-text'); } else { el.style.setProperty('--ov-text-scale', String(parseInt(d.text, 10) / 100)); el.setAttribute('data-ov-text', d.text); }
++ for (const k in COMFORT_ATTRS) { const [attr, off] = COMFORT_ATTRS[k]; if (d[k] && d[k] !== off) el.setAttribute(attr, d[k]); else el.removeAttribute(attr); }
if (!document.getElementById('ov-display-css') && document.head) {
const st = document.createElement('style'); st.id = 'ov-display-css';
-- st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-mark .o,html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}';
++ st.textContent = 'html[data-ov-text]{font-size:calc(100% * var(--ov-text-scale,1))}html[data-ov-motion=reduced] *,html[data-ov-motion=reduced] *::before,html[data-ov-motion=reduced] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}html[data-ov-motion=reduced] .ov-mark .o,html[data-ov-motion=reduced] .ov-icon .ovi-comet{display:none}' + COMFORT_CSS;
document.head.appendChild(st);
}
try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
@@@ -577,7 +588,7 @@
fetch(_config.apiBase + '/api/themes/me/active' + (site ? '?service=' + encodeURIComponent(site) : ''), { headers: headers, credentials: 'include' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
-- if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text, seasonal: data.display.seasonal || cur.seasonal, scheme: data.display.scheme || cur.scheme }) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true }); } }
++ if (data && data.display) { const cur = readDisplay(); const next = {}; for (const k in DISPLAY_OPTS) next[k] = data.display[k] || cur[k]; if (JSON.stringify(next) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true }); } }
if (!data || !data.theme_id) return;
const serverId = data.theme_id;
const custom = (data.custom_variables && typeof data.custom_variables === 'object'