openvibers/openvibe.shared Commits
4.45.0: comfort settings beside the theme (#185)
Changes
@@@ -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@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -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)",@@@ -73,6 +73,22 @@ Status: the generated Kotlin, Swift and Dart are checked against the Compose Matreview and by the tests' expected lines. This host has no Kotlin, Swift or Dart toolchain to compile them; a nativeCI 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@@@ -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",@@@ -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": {nnew file mode 100644@@@ -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');@@@ -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'
@@ -6,6 +6,22 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,22 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 4.44.0 — 2026-10-29
- **`showcase.fold({ id, title, hint, html, open })`** folds read-once material (how it works, the code samples, what
- **`showcase.fold({ id, title, hint, html, open })`** folds read-once material (how it works, the code samples, what
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.44.0",
+ "version": "4.45.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -73,6 +73,22 @@ Status: the generated Kotlin, Swift and Dart are checked against the Compose Mat
@@ -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
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.
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)
## 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
The owner, 2026-10-29: "translate it to official and optimized Android and iOS apps in the future that aren't just web
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.44.0",
+ "version": "4.45.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.44.0",
+ "version": "4.45.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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');
@@ -389,15 +389,25 @@
@@ -389,15 +389,25 @@
// ── Auto-apply on load (synchronous, prevents FOUC) ─────
// ── Auto-apply on load (synchronous, prevents FOUC) ─────
// ── Display preferences: motion + text size, one setting for every OpenVibe site ─────────────
// ── 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
// 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
// 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.
// 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)
// 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)
// 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() {
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) { /* */ }
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;
return out;
}
}
@@ -406,9 +416,10 @@
@@ -406,9 +416,10 @@
const el = document.documentElement;
const el = document.documentElement;
if (d.motion === 'reduced') el.setAttribute('data-ov-motion', 'reduced'); else el.removeAttribute('data-ov-motion');
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); }
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) {
if (!document.getElementById('ov-display-css') && document.head) {
const st = document.createElement('style'); st.id = 'ov-display-css';
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);
document.head.appendChild(st);
}
}
try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
try { window.dispatchEvent(new CustomEvent('ov:display', { detail: d })); } catch (e) { /* */ }
@@ -577,7 +588,7 @@
@@ -577,7 +588,7 @@
fetch(_config.apiBase + '/api/themes/me/active' + (site ? '?service=' + encodeURIComponent(site) : ''), { headers: headers, credentials: 'include' })
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 (r) { return r.ok ? r.json() : null; })
.then(function (data) {
.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;
if (!data || !data.theme_id) return;
const serverId = data.theme_id;
const serverId = data.theme_id;
const custom = (data.custom_variables && typeof data.custom_variables === 'object'
const custom = (data.custom_variables && typeof data.custom_variables === 'object'