openvibers/openvibe.shared Commits

4.111.1: the tab icon is the Pair on every site (#291)

OP OpenVibers committed 7 days ago · db4f0b35ea · 9 files, +133 −15

theme-loader.js repainted every site's favicon with the retired ring mark on each theme apply. It now draws
app-icon.js's favicon() (heavy form, site glow and light) on the theme's ground, generated into a FAVICON block by
scripts/build-theme-loader.js so it cannot drift from mark.js and app-icon.js.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8de9ae0..834b91b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,16 @@
# Changelog
++## 4.111.1 — 2026-11-05
++
++- **The tab icon is the Pair on every site.** `theme-loader.js` repaints the favicon on each theme apply
++ (`<link rel="icon" data-ov-icon>`), and it still drew the retired ring mark: every site's inline Pair favicon turned
++ into the old ring once the page loaded. It now draws `app-icon.js`'s `favicon()` (the heavy form, the site's glow,
++ Live's red light and Games' yellow one) with the theme's `--bg-primary` as the tile; the theme accent no longer
++ recolours the mark. The drawing is generated into the loader's `FAVICON` block by `scripts/build-theme-loader.js`
++ from `mark.js` and `app-icon.js` (`--check` fails when stale), so it cannot drift again.
++- Tests: `test/favicon-pair.test.js` (each site equals `favicon()`, the lights, the tile follows the theme, the block
++ is current).
++
## 4.111.0 — 2026-11-04
- **`openvibe-shared/vibes-pay` (`vibes-pay.js`): Vibes Pay for any page** (MONEY.md, D12).
ddiff --git a/README.md b/README.md
iindex 000432c..f513727 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.111.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.111.1 (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 edb226d..bfe1039 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.111.0",
++ "version": "4.111.1",
"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/package-lock.json b/package-lock.json
iindex 62b0fda..45295c5 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.111.0",
++ "version": "4.111.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.111.0",
++ "version": "4.111.1",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex aae8bbd..3ba85a7 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.111.0",
++ "version": "4.111.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/scripts/build-theme-loader.js b/scripts/build-theme-loader.js
iindex 719dd67..09fb712 100644
---- a/scripts/build-theme-loader.js
++++ b/scripts/build-theme-loader.js
@@@ -20,6 +20,8 @@
const fs = require('fs');
const path = require('path');
const { BUILTIN_THEMES, DEFAULT_VARS, SEASONS } = require('../builtin-themes');
++const MARK = require('../mark');
++const APP_ICON = require('../app-icon');
const LOADER = path.join(__dirname, '..', 'theme-loader.js');
const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';
@@@ -63,6 +65,31 @@ ${seasons.join('\n')}
${END}`;
}
++// The tab icon (syncBrowserUi) is app-icon.js's favicon(): the Pair, heavy form, with each site's glow and light. Its
++// parts are written here so the loader draws exactly what the PNG and inline favicons show (test/favicon-pair.test.js).
++const FAV_BEGIN = ' // FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand';
++const FAV_END = ' // FAVICON:END';
++function renderFavicon() {
++ const g = MARK.geo('heavy'), { defs, body } = MARK.art({ form: 'heavy', id: 'ov', depth: true });
++ const q = (v) => `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
++ const sites = Object.entries(APP_ICON.SITES).map(([id, c]) => `${id}: [${q(c.accent[0])}, ${q(c.dot && c.dot.toLowerCase() !== '#ffffff' ? c.dot : '')}]`).join(', ');
++ return `${FAV_BEGIN}
++ const FAV_DEFS = ${q(defs)};
++ const FAV_BODY = ${q(body)};
++ const FAV_LIGHT = { cx: ${g.cx}, cy: ${g.cy}, r: ${+((g.r - g.sw / 2) * 0.62).toFixed(2)} };
++ const FAV_SITES = { ${sites} };
++${FAV_END}`;
++}
++function faviconBlock(src) {
++ const a = src.indexOf(FAV_BEGIN), b = src.indexOf(FAV_END);
++ if (a >= 0 && b > a) return { a, b: b + FAV_END.length };
++ // First run: the old ring mark's site lights.
++ const old = " const DOTS = { live: '#ef4444', games: '#facc15' };";
++ const at = src.indexOf(old);
++ if (at < 0) throw new Error('theme-loader.js: FAVICON block not found');
++ return { a: at, b: at + old.length };
++}
++
function currentBlock(src) {
const a = src.indexOf(BEGIN);
const b = src.indexOf(END);
@@@ -76,7 +103,9 @@ function currentBlock(src) {
const src = fs.readFileSync(LOADER, 'utf8');
const { a, b } = currentBlock(src);
--const next = src.slice(0, a) + render() + src.slice(b);
++const themed = src.slice(0, a) + render() + src.slice(b);
++const fav = faviconBlock(themed);
++const next = themed.slice(0, fav.a) + renderFavicon() + themed.slice(fav.b);
const check = process.argv.includes('--check');
if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
if (check) { console.error('theme-loader.js is stale: run node scripts/build-theme-loader.js'); process.exit(1); }
ddiff --git a/test/favicon-pair.test.js b/test/favicon-pair.test.js
nnew file mode 100644
iindex 0000000..bb1dbda
---- /dev/null
++++ b/test/favicon-pair.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++// The tab icon theme-loader.js paints (syncBrowserUi, on every theme apply) is the Pair: app-icon.js's favicon() with
++// the theme's ground for the tile. Until 4.111.1 it redrew the retired ring mark over every site's inline Pair favicon
++// as soon as the page loaded. theme-loader.js runs in a small fake browser, as in display-comfort.test.js.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { execFileSync } = require('child_process');
++const APP_ICON = require('../app-icon');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');
++
++function paint(site, vars = {}) {
++ const attrs = new Map();
++ const link = { type: '', href: 'data:image/svg+xml,inline', getAttribute: (k) => (k === 'data-ov-icon' ? site : null) };
++ const meta = { setAttribute() {} };
++ const html = { style: { setProperty() {}, removeProperty() {} }, setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k), classList: { add() {}, remove() {}, contains: () => false } };
++ const sandbox = {
++ JSON, Object, String, Number, Math, Array, Promise, Error, URL, URLSearchParams, Date, encodeURIComponent, setTimeout() {}, clearTimeout() {},
++ document: {
++ documentElement: html, readyState: 'complete', cookie: '', head: { appendChild() {} }, body: null,
++ querySelector: (sel) => (sel.includes('data-ov-icon') ? link : sel.includes('theme-color') ? meta : null),
++ getElementById: () => null, createElement: () => ({ setAttribute() {}, style: {} }), addEventListener() {},
++ },
++ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
++ sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
++ location: { hostname: `openvibe.${site}`, protocol: 'https:', search: '', href: `https://openvibe.${site}/` },
++ history: { state: null, replaceState() {} },
++ getComputedStyle: () => ({ getPropertyValue: (k) => vars[k] || '' }),
++ matchMedia: () => ({ matches: false, addEventListener() {} }),
++ fetch: () => Promise.resolve({ ok: false, json: () => Promise.resolve(null) }),
++ 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);
++ assert.ok(link.href.startsWith('data:image/svg+xml,'), `${site}: the loader painted the tab icon`);
++ return decodeURIComponent(link.href.slice('data:image/svg+xml,'.length));
++}
++
++// app-icon.js's favicon() with the theme's ground in place of its own tile gradient.
++const expected = (site, bg) => APP_ICON.favicon({ site })
++ .replace(/\n<radialGradient id="bg"[\s\S]*?<\/radialGradient>/, '')
++ .replace('fill="url(#bg)"', `fill="${bg}"`);
++
++// 1. Every site app-icon.js knows, and an unknown one (Network's), draw the same Pair as the build-time favicon.
++for (const site of [...Object.keys(APP_ICON.SITES), 'chat']) {
++ const got = paint(site, { '--bg-primary': '#0a0f1c' });
++ const want = expected(APP_ICON.SITES[site] ? site : 'network', '#0a0f1c');
++ assert.strictEqual(got, want, `${site}: the loader's tab icon is app-icon.js's favicon()`);
++ assert.ok(!got.includes('M-9.5,-7 L0,10 L9.5,-7'), `${site}: not the retired ring mark`);
++ assert.ok(got.includes('mask="url(#ovh)"'), `${site}: the Pair, with its mask gap`);
++}
++
++// 2. Live keeps its red record light and Games its yellow one; Tools has none.
++assert.ok(paint('live').includes('fill="#ef4444"'), 'Live: the red light in the O');
++assert.ok(paint('games').includes('fill="#facc15"'), 'Games: the yellow light');
++assert.ok(!/<circle cx="30\.09" cy="50" r="5\.58"/.test(paint('tools')), 'Tools: no light');
++
++// 3. The tile follows the theme's ground; the mark keeps its brand colours.
++const light = paint('tools', { '--bg-primary': '#f8fafc', '--accent': '#0f766e' });
++assert.ok(light.includes('rx="15" fill="#f8fafc"'), 'a light theme gets a light tile');
++assert.ok(!light.includes('#0f766e'), 'the theme accent does not recolour the mark');
++
++// 4. The generated block is current.
++execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'build-theme-loader.js'), '--check'], { stdio: 'pipe' });
++
++console.log('favicon pair: all checks passed');
ddiff --git a/test/theme-parity.test.js b/test/theme-parity.test.js
iindex 0bf771b..2739a11 100644
---- a/test/theme-parity.test.js
++++ b/test/theme-parity.test.js
@@@ -49,7 +49,8 @@ for (const t of BUILTIN_THEMES) {
}
assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');
// 62,000 since 4.64.0: the look travels between sites (?ov_look=) and every copy is dated; brotli 15.45 KB (was 15.41) (2026-10-31).
--assert.ok(loader.length < 62000, `theme-loader.js stays small for first paint (${loader.length} characters)`);
++// 63,500 since 4.111.1 (2026-11-05): the FAVICON block draws the Pair (about 1.2 KB raw) instead of the retired ring.
++assert.ok(loader.length < 63500, `theme-loader.js stays small for first paint (${loader.length} characters)`);
assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');
// 5. Derived tokens: text on the accent always reads, on every theme.
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 50d232a..3b31040 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -232,17 +232,24 @@
// ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),
// and window 'ov:theme' { accent, accentLight, bg, mode }.
-- const DOTS = { live: '#ef4444', games: '#facc15' };
++ // FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand
++ const FAV_DEFS = '<mask id="ovh" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="#000" stroke-width="24" stroke-linecap="round"/></mask><linearGradient id="ovgo" gradientUnits="userSpaceOnUse" x1="3.09" y1="23" x2="57.09" y2="77"><stop offset="0" stop-color="#87a6ff"/><stop offset="1" stop-color="#4268d6"/></linearGradient><linearGradient id="ovgv" gradientUnits="userSpaceOnUse" x1="0" y1="31.5" x2="0" y2="68.5"><stop offset="0" stop-color="#ae8df9"/><stop offset="1" stop-color="#784fd4"/></linearGradient>';
++ const FAV_BODY = '<circle cx="30.09" cy="50" r="18" fill="none" stroke="url(#ovgo)" stroke-width="18" mask="url(#ovh)"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="url(#ovgv)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>';
++ const FAV_LIGHT = { cx: 30.09, cy: 50, r: 5.58 };
++ const FAV_SITES = { network: ['#60a5fa', ''], live: ['#60a5fa', '#ef4444'], tools: ['#38bdf8', ''], community: ['#34d399', ''], games: ['#a78bfa', '#facc15'], media: ['#fbbf24', ''], space: ['#818cf8', ''], ai: ['#22d3ee', ''] };
++ // FAVICON:END
const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;
function readColor(name, fallback) {
try { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return COLOR_RE.test(v) ? v : fallback; } catch { return fallback; }
}
-- function faviconSvg(site, accent, light, bg) {
-- const e = encodeURIComponent;
-- return 'data:image/svg+xml,' + e('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs><linearGradient id="r" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + light + '"/><stop offset="1" stop-color="' + accent + '"/></linearGradient></defs>'
-- + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(1.22)"><circle r="18" fill="none" stroke="url(#r)" stroke-width="4" opacity=".38"/>'
-- + '<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#r)" stroke-width="4.4" stroke-linecap="round"/><path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="' + (document.documentElement.getAttribute('data-theme-mode') === 'light' ? accent : '#fff') + '" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>'
-- + '<circle cx="17.4" cy="4.6" r="3.2" fill="' + (DOTS[site] || light) + '"/></g></svg>');
++ /** The tab icon: app-icon.js's favicon() (the Pair, heavy form, the site's glow and light) on the theme's ground. */
++ function faviconSvg(site, bg) {
++ const s = FAV_SITES[site] || FAV_SITES.network, a = s[0];
++ const light = s[1] ? '<circle cx="' + FAV_LIGHT.cx + '" cy="' + FAV_LIGHT.cy + '" r="' + FAV_LIGHT.r + '" fill="' + s[1] + '"/>' : '';
++ return 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs>' + FAV_DEFS
++ + '\n<radialGradient id="glow"><stop offset="0" stop-color="' + a + '" stop-opacity=".3"/><stop offset="1" stop-color="' + a + '" stop-opacity="0"/></radialGradient></defs>'
++ + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(0.58) translate(-50 -50)"><ellipse cx="50" cy="50" rx="52" ry="40" fill="url(#glow)"/>'
++ + FAV_BODY + light + '</g></svg>');
}
let _browserUiKey = '';
function syncBrowserUi() {
@@@ -255,7 +262,7 @@
if (!meta) { meta = document.createElement('meta'); meta.name = 'theme-color'; document.head.appendChild(meta); }
meta.setAttribute('content', bg);
const link = document.querySelector('link[rel~="icon"][data-ov-icon]');
-- if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), accent, light, readColor('--bg-primary', '#0a0f1c')); }
++ if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), readColor('--bg-primary', '#0a0f1c')); }
} catch { /* cosmetic */ }
try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }
}